@crup/react-timer-hook 0.0.1-alpha.10

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rajender Joshi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # @crup/react-timer-hook
2
+
3
+ > React timer hooks for countdowns, stopwatches, clocks, polling schedules, and many independent timer lifecycles.
4
+
5
+ [![npm alpha](https://img.shields.io/npm/v/%40crup%2Freact-timer-hook/alpha?label=npm%20alpha&color=00b894)](https://www.npmjs.com/package/@crup/react-timer-hook?activeTab=versions)
6
+ [![npm downloads](https://img.shields.io/npm/dm/%40crup%2Freact-timer-hook?color=0f766e)](https://www.npmjs.com/package/@crup/react-timer-hook)
7
+ [![CI](https://github.com/crup/react-timer-hook/actions/workflows/ci.yml/badge.svg)](https://github.com/crup/react-timer-hook/actions/workflows/ci.yml)
8
+ [![Docs](https://github.com/crup/react-timer-hook/actions/workflows/docs.yml/badge.svg)](https://github.com/crup/react-timer-hook/actions/workflows/docs.yml)
9
+ [![Size](https://github.com/crup/react-timer-hook/actions/workflows/size.yml/badge.svg)](https://github.com/crup/react-timer-hook/actions/workflows/size.yml)
10
+ [![license](https://img.shields.io/npm/l/%40crup%2Freact-timer-hook?color=111827)](./LICENSE)
11
+ [![types](https://img.shields.io/npm/types/%40crup%2Freact-timer-hook?color=2563eb)](https://www.npmjs.com/package/@crup/react-timer-hook)
12
+ [![React](https://img.shields.io/npm/dependency-version/%40crup%2Freact-timer-hook/peer/react?label=react&color=149eca)](https://react.dev/)
13
+
14
+ 📚 Docs and live examples: https://crup.github.io/react-timer-hook/
15
+
16
+ ## Why this exists
17
+
18
+ Timer hooks look simple until real apps need pause/resume semantics, Strict Mode cleanup, async callbacks, polling that does not overlap, and lists with dozens of independent timers.
19
+
20
+ `@crup/react-timer-hook` keeps the API small and lets your app decide what time means:
21
+
22
+ - ⏱️ `useTimer()` for one lifecycle: stopwatch, countdown, clock, schedule, or custom flow.
23
+ - 🧭 `useTimerGroup()` for many keyed lifecycles with one shared scheduler.
24
+ - 🧩 `durationParts()` for display math without locale or timezone opinions.
25
+ - 🧼 No formatting, timezone, audio, retry, cache, or data-fetching policy baked in.
26
+ - 🧪 Built for rerenders, Strict Mode, async callbacks, cleanup, and many timers.
27
+ - 🤖 Agent-friendly docs through hosted `llms.txt`, `llms-full.txt`, and an optional MCP docs helper.
28
+
29
+ ## Install
30
+
31
+ The project is currently in alpha while the API receives feedback.
32
+
33
+ ```sh
34
+ npm install @crup/react-timer-hook@alpha
35
+ pnpm add @crup/react-timer-hook@alpha
36
+ ```
37
+
38
+ ```tsx
39
+ import { durationParts, useTimer, useTimerGroup } from '@crup/react-timer-hook';
40
+ ```
41
+
42
+ ## Live recipes
43
+
44
+ Each recipe has a live playground and a focused code sample:
45
+
46
+ - Basic: [wall clock](https://crup.github.io/react-timer-hook/recipes/basic/wall-clock/), [stopwatch](https://crup.github.io/react-timer-hook/recipes/basic/stopwatch/), [absolute countdown](https://crup.github.io/react-timer-hook/recipes/basic/absolute-countdown/), [pausable countdown](https://crup.github.io/react-timer-hook/recipes/basic/pausable-countdown/), [manual controls](https://crup.github.io/react-timer-hook/recipes/basic/manual-controls/)
47
+ - Intermediate: [once-only onEnd](https://crup.github.io/react-timer-hook/recipes/intermediate/once-only-on-end/), [polling schedule](https://crup.github.io/react-timer-hook/recipes/intermediate/polling-schedule/), [poll and cancel](https://crup.github.io/react-timer-hook/recipes/intermediate/poll-and-cancel/), [backend event stop](https://crup.github.io/react-timer-hook/recipes/intermediate/backend-event-stop/), [debug logs](https://crup.github.io/react-timer-hook/recipes/intermediate/debug-logs/)
48
+ - Advanced: [many display countdowns](https://crup.github.io/react-timer-hook/recipes/advanced/many-display-countdowns/), [timer group](https://crup.github.io/react-timer-hook/recipes/advanced/timer-group/), [group controls](https://crup.github.io/react-timer-hook/recipes/advanced/group-controls/), [per-item polling](https://crup.github.io/react-timer-hook/recipes/advanced/per-item-polling/), [dynamic items](https://crup.github.io/react-timer-hook/recipes/advanced/dynamic-items/)
49
+
50
+ ## Quick examples
51
+
52
+ ### Stopwatch
53
+
54
+ ```tsx
55
+ import { useTimer } from '@crup/react-timer-hook';
56
+
57
+ export function Stopwatch() {
58
+ const timer = useTimer({ updateIntervalMs: 100 });
59
+
60
+ return (
61
+ <>
62
+ <output>{(timer.elapsedMilliseconds / 1000).toFixed(1)}s</output>
63
+ <button disabled={!timer.isIdle} onClick={timer.start}>Start</button>
64
+ <button disabled={!timer.isRunning} onClick={timer.pause}>Pause</button>
65
+ <button disabled={!timer.isPaused} onClick={timer.resume}>Resume</button>
66
+ <button onClick={timer.restart}>Restart</button>
67
+ </>
68
+ );
69
+ }
70
+ ```
71
+
72
+ ### Auction countdown
73
+
74
+ Use `now` for wall-clock deadlines from a server, auction, reservation, or job expiry.
75
+
76
+ ```tsx
77
+ import { useTimer } from '@crup/react-timer-hook';
78
+
79
+ export function AuctionTimer({ auctionId, expiresAt }: {
80
+ auctionId: string;
81
+ expiresAt: number;
82
+ }) {
83
+ const timer = useTimer({
84
+ autoStart: true,
85
+ updateIntervalMs: 1000,
86
+ endWhen: snapshot => snapshot.now >= expiresAt,
87
+ onEnd: () => api.closeAuction(auctionId),
88
+ });
89
+
90
+ const remainingMs = Math.max(0, expiresAt - timer.now);
91
+
92
+ if (timer.isEnded) return <span>Auction ended</span>;
93
+ return <span>{Math.ceil(remainingMs / 1000)}s left</span>;
94
+ }
95
+ ```
96
+
97
+ ### Poll and cancel early
98
+
99
+ Schedules run while the timer is active. Slow async work is skipped by default with `overlap: 'skip'`.
100
+
101
+ ```tsx
102
+ const timer = useTimer({
103
+ autoStart: true,
104
+ updateIntervalMs: 1000,
105
+ endWhen: snapshot => snapshot.now >= expiresAt,
106
+ schedules: [
107
+ {
108
+ id: 'auction-poll',
109
+ everyMs: 5000,
110
+ overlap: 'skip',
111
+ callback: async (_snapshot, controls, context) => {
112
+ console.log(`auction poll fired ${context.firedAt - context.scheduledAt}ms late`);
113
+ const auction = await api.getAuction(auctionId);
114
+ if (auction.status === 'sold') controls.cancel('sold');
115
+ },
116
+ },
117
+ ],
118
+ });
119
+ ```
120
+
121
+ ### Many independent timers
122
+
123
+ Use `useTimerGroup()` when every row needs its own pause, resume, cancel, restart, schedules, or `onEnd`.
124
+
125
+ ```tsx
126
+ const timers = useTimerGroup({
127
+ updateIntervalMs: 1000,
128
+ items: auctions.map(auction => ({
129
+ id: auction.id,
130
+ autoStart: true,
131
+ endWhen: snapshot => snapshot.now >= auction.expiresAt,
132
+ onEnd: () => api.closeAuction(auction.id),
133
+ })),
134
+ });
135
+ ```
136
+
137
+ ## Bundle size
138
+
139
+ Current build:
140
+
141
+ | File | Raw | Gzip | Brotli |
142
+ | --- | ---: | ---: | ---: |
143
+ | `dist/index.js` | 12.80 kB | 3.88 kB | 3.47 kB |
144
+ | `dist/index.cjs` | 14.04 kB | 4.12 kB | 3.70 kB |
145
+ | `dist/index.d.ts` | 4.32 kB | 1.04 kB | 951 B |
146
+
147
+ CI writes a size summary to the GitHub Actions UI and posts bundle-size reports on pull requests.
148
+
149
+ ## AI-friendly docs
150
+
151
+ Agents and docs-aware IDEs can use:
152
+
153
+ - https://crup.github.io/react-timer-hook/llms.txt
154
+ - https://crup.github.io/react-timer-hook/llms-full.txt
155
+
156
+ Optional local MCP docs server:
157
+
158
+ ```json
159
+ {
160
+ "mcpServers": {
161
+ "react-timer-hook-docs": {
162
+ "command": "node",
163
+ "args": ["/absolute/path/to/react-timer-hook/mcp/server.mjs"]
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ It exposes:
170
+
171
+ ```txt
172
+ react-timer-hook://package
173
+ react-timer-hook://api
174
+ react-timer-hook://recipes
175
+ ```
176
+
177
+ ## Contributing
178
+
179
+ Issues, recipes, docs improvements, and focused bug reports are welcome.
180
+
181
+ - Read the docs: https://crup.github.io/react-timer-hook/
182
+ - Open an issue: https://github.com/crup/react-timer-hook/issues
183
+ - See the contributing guide: ./CONTRIBUTING.md
184
+
185
+ The package targets Node 24 for development and React 18+ as a peer dependency.
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";var de=Object.defineProperty;var he=Object.getOwnPropertyDescriptor;var ye=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var Ae=(e,o)=>{for(var a in o)de(e,a,{get:o[a],enumerable:!0})},Ie=(e,o,a,n)=>{if(o&&typeof o=="object"||typeof o=="function")for(let y of ye(o))!we.call(e,y)&&y!==a&&de(e,y,{get:()=>o[y],enumerable:!(n=he(o,y))||n.enumerable});return e};var Me=e=>Ie(de({},"__esModule",{value:!0}),e);var Re={};Ae(Re,{durationParts:()=>fe,useTimer:()=>ge,useTimerGroup:()=>Te});module.exports=Me(Re);function fe(e){let o=Math.max(0,Math.trunc(Number.isFinite(e)?e:0)),a=Math.floor(o/864e5),n=o%864e5,y=Math.floor(n/36e5),M=n%36e5,T=Math.floor(M/6e4),v=M%6e4,D=Math.floor(v/1e3);return{totalMilliseconds:o,totalSeconds:Math.floor(o/1e3),milliseconds:v%1e3,seconds:D,minutes:T,hours:y,days:a}}var m=require("react");function ve(e){return e?e===!0?{enabled:!0,includeTicks:!1,logger:console.debug}:typeof e=="function"?{enabled:!0,includeTicks:!1,logger:e}:{enabled:e.enabled!==!1,includeTicks:e.includeTicks??!1,label:e.label,logger:e.logger??console.debug}:{enabled:!1,includeTicks:!1}}function E(e,o){let a=ve(e);!a.enabled||!a.logger||o.type==="timer:tick"&&!a.includeTicks||a.logger({...o,label:o.label??a.label})}function G(e,o){return{generation:o,tick:e.tick,now:e.now,elapsedMilliseconds:e.elapsedMilliseconds,status:e.status}}function f(){let e=Date.now(),o=typeof performance<"u"&&typeof performance.now=="function"?performance.now():e;return{wallNow:e,monotonicNow:o}}function j(e,o){if(!Number.isFinite(e)||e<=0)throw new RangeError(`${o} must be a finite number greater than 0`)}function ne(e){return{status:"idle",generation:0,tick:0,startedAt:null,pausedAt:null,endedAt:null,cancelledAt:null,cancelReason:null,baseElapsedMilliseconds:0,activeStartedAtMonotonic:null,now:e.wallNow}}function oe(e,o){return e.status!=="running"||e.activeStartedAtMonotonic===null?e.baseElapsedMilliseconds:Math.max(0,e.baseElapsedMilliseconds+o.monotonicNow-e.activeStartedAtMonotonic)}function S(e,o){let a=oe(e,o);return{status:e.status,now:o.wallNow,tick:e.tick,startedAt:e.startedAt,pausedAt:e.pausedAt,endedAt:e.endedAt,cancelledAt:e.cancelledAt,cancelReason:e.cancelReason,elapsedMilliseconds:a,isIdle:e.status==="idle",isRunning:e.status==="running",isPaused:e.status==="paused",isEnded:e.status==="ended",isCancelled:e.status==="cancelled"}}function q(e,o){return e.status!=="idle"?!1:(e.status="running",e.startedAt=o.wallNow,e.pausedAt=null,e.endedAt=null,e.cancelledAt=null,e.cancelReason=null,e.activeStartedAtMonotonic=o.monotonicNow,e.now=o.wallNow,!0)}function ue(e,o){return e.status!=="running"?!1:(e.baseElapsedMilliseconds=oe(e,o),e.activeStartedAtMonotonic=null,e.status="paused",e.pausedAt=o.wallNow,e.now=o.wallNow,!0)}function se(e,o){return e.status!=="paused"?!1:(e.status="running",e.pausedAt=null,e.activeStartedAtMonotonic=o.monotonicNow,e.now=o.wallNow,!0)}function te(e,o,a={}){return e.generation+=1,e.tick=0,e.status=a.autoStart?"running":"idle",e.startedAt=a.autoStart?o.wallNow:null,e.pausedAt=null,e.endedAt=null,e.cancelledAt=null,e.cancelReason=null,e.baseElapsedMilliseconds=0,e.activeStartedAtMonotonic=a.autoStart?o.monotonicNow:null,e.now=o.wallNow,!0}function ae(e,o){return te(e,o,{autoStart:!0})}function ie(e,o,a){return e.status==="ended"||e.status==="cancelled"?!1:(e.baseElapsedMilliseconds=oe(e,o),e.activeStartedAtMonotonic=null,e.status="cancelled",e.cancelledAt=o.wallNow,e.cancelReason=a??null,e.now=o.wallNow,!0)}function le(e,o){return e.status!=="running"?!1:(e.baseElapsedMilliseconds=oe(e,o),e.activeStartedAtMonotonic=null,e.status="ended",e.endedAt=o.wallNow,e.now=o.wallNow,!0)}function ce(e,o){return e.status!=="running"?!1:(e.tick+=1,e.now=o.wallNow,!0)}function ge(e={}){let o=e.updateIntervalMs??1e3;j(o,"updateIntervalMs"),ke(e.schedules);let a=(0,m.useRef)(e);a.current=e;let n=(0,m.useRef)(null);n.current===null&&(n.current=ne(f()));let y=(0,m.useRef)(!1),M=(0,m.useRef)(null),T=(0,m.useRef)(new Map),v=(0,m.useRef)(null),[,D]=(0,m.useReducer)(s=>s+1,0),c=(0,m.useCallback)(()=>{M.current!==null&&(clearTimeout(M.current),M.current=null)},[]),U=(0,m.useCallback)((s=f())=>S(n.current,s),[]),h=(0,m.useCallback)((s,d,g={})=>{E(a.current.debug,{type:s,scope:"timer",...G(d,n.current.generation),...g})},[]),$=(0,m.useRef)(null),B=(0,m.useCallback)(s=>{let d=n.current.generation;if(v.current!==d){v.current=d;try{a.current.onEnd?.(s,$.current)}catch(g){E(a.current.debug,{type:"callback:error",scope:"timer",...G(s,d),error:g})}}},[]),k=(0,m.useCallback)((s,d,g,R,C,p)=>{if(g.pending&&(s.overlap??"skip")==="skip"){g.lastRunAt=p.scheduledAt,E(a.current.debug,{type:"schedule:skip",scope:"timer",reason:"overlap",...p,...G(R,C)});return}g.lastRunAt=p.scheduledAt,g.pending=!0,E(a.current.debug,{type:"schedule:start",scope:"timer",...p,...G(R,C)}),Promise.resolve().then(()=>s.callback(R,$.current,p)).then(()=>{E(a.current.debug,{type:"schedule:end",scope:"timer",...p,...G(R,C)})},K=>{E(a.current.debug,{type:"schedule:error",scope:"timer",error:K,...p,...G(R,C)})}).finally(()=>{n.current?.generation===C&&(g.pending=!1)})},[]),J=(0,m.useCallback)((s,d,g=!1)=>{let R=a.current.schedules??[],C=new Set;R.forEach((p,K)=>{let N=p.id??String(K);C.add(N);let I=T.current.get(N);if(I||(I={lastRunAt:null,pending:!1,leadingGeneration:null},T.current.set(N,I)),g&&p.leading&&I.leadingGeneration!==d){I.leadingGeneration=d,k(p,N,I,s,d,pe(p,N,s.now,s.now,0));return}I.lastRunAt===null&&(I.lastRunAt=n.current.startedAt??s.now);let W=Math.floor((s.now-I.lastRunAt)/p.everyMs);if(W>=1){let re=I.lastRunAt+W*p.everyMs;k(p,N,I,s,d,pe(p,N,re,s.now,W-1))}});for(let p of T.current.keys())C.has(p)||T.current.delete(p)},[k]),A=(0,m.useCallback)((s=f(),d=!1)=>{let g=n.current;if(g.status!=="running")return;let R=S(g,s),C=g.generation;if(a.current.endWhen?.(R)){if(le(g,s)){let p=S(g,s);h("timer:end",p),c(),B(p),D()}return}J(R,C,d)},[B,c,h,J]),Q=(0,m.useCallback)((s=f())=>{let d=a.current.updateIntervalMs??1e3,g=d;return n.current.status!=="running"?d:((a.current.schedules??[]).forEach((p,K)=>{let N=p.id??String(K),W=T.current.get(N)?.lastRunAt??s.wallNow;g=Math.min(g,Math.max(1,W+p.everyMs-s.wallNow))}),g)},[]),V=(0,m.useCallback)(()=>{let s=f();if(!q(n.current,s))return;let d=S(n.current,s);h("timer:start",d),A(s,!0),D()},[h,A]),X=(0,m.useCallback)(()=>{let s=f();if(!ue(n.current,s))return;c();let d=S(n.current,s);h("timer:pause",d),D()},[c,h]),Z=(0,m.useCallback)(()=>{let s=f();if(!se(n.current,s))return;let d=S(n.current,s);h("timer:resume",d),A(s,!0),D()},[h,A]),_=(0,m.useCallback)((s={})=>{let d=f();c(),te(n.current,d,s),T.current.clear(),v.current=null;let g=S(n.current,d);h("timer:reset",g),s.autoStart&&A(d,!0),D()},[c,h,A]),H=(0,m.useCallback)(()=>{let s=f();c(),ae(n.current,s),T.current.clear(),v.current=null;let d=S(n.current,s);h("timer:restart",d),A(s,!0),D()},[c,h,A]),L=(0,m.useCallback)(s=>{let d=f();if(!ie(n.current,d,s))return;c();let g=S(n.current,d);h("timer:cancel",g,{reason:s}),D()},[c,h]);$.current=(0,m.useMemo)(()=>({start:V,pause:X,resume:Z,reset:_,restart:H,cancel:L}),[L,X,_,H,Z,V]),(0,m.useEffect)(()=>(y.current=!0,a.current.autoStart&&n.current.status==="idle"&&$.current.start(),()=>{y.current=!1,c()}),[c]);let F=U(),Y=n.current.generation,z=F.status;return(0,m.useEffect)(()=>{if(!y.current||z!=="running"){c();return}return c(),h("scheduler:start",U()),M.current=setTimeout(()=>{if(!y.current||n.current.generation!==Y||n.current.status!=="running")return;let s=f();ce(n.current,s);let d=S(n.current,s);h("timer:tick",d),A(s),D()},Q()),()=>{M.current!==null&&h("scheduler:stop",U()),c()}},[c,h,Y,Q,U,A,F.tick,z]),{...F,...$.current}}function ke(e){e?.forEach(o=>j(o.everyMs,"schedule.everyMs"))}function pe(e,o,a,n,y){return{scheduleId:e.id??o,scheduledAt:a,firedAt:n,nextRunAt:a+e.everyMs,overdueCount:y,effectiveEveryMs:e.everyMs}}var l=require("react");function Te(e={}){let o=e.updateIntervalMs??1e3;j(o,"updateIntervalMs"),me(e.items);let a=(0,l.useRef)(e);a.current=e;let n=(0,l.useRef)(new Map),y=(0,l.useRef)(!1),M=(0,l.useRef)(null),[,T]=(0,l.useReducer)(t=>t+1,0),v=(0,l.useCallback)(()=>{M.current!==null&&(clearTimeout(M.current),M.current=null)},[]),D=(0,l.useCallback)((t,r=f())=>S(t.state,r),[]),c=(0,l.useCallback)((t,r,u,i={})=>{E(a.current.debug,{type:t,scope:"timer-group",timerId:r?.id,...G(u,r?.state.generation??0),...i})},[]),U=(0,l.useCallback)(t=>({start:()=>H(t),pause:()=>L(t),resume:()=>F(t),reset:r=>Y(t,r),restart:()=>z(t),cancel:r=>s(t,r)}),[]),h=(0,l.useCallback)((t,r)=>{let u=t.state.generation;if(t.endCalledGeneration!==u){t.endCalledGeneration=u;try{t.definition.onEnd?.(r,U(t.id))}catch(i){E(a.current.debug,{type:"callback:error",scope:"timer-group",timerId:t.id,error:i,...G(r,u)})}}},[U]),$=(0,l.useCallback)((t,r,u,i,w,b,O)=>{if(i.pending&&(r.overlap??"skip")==="skip"){i.lastRunAt=O.scheduledAt,E(a.current.debug,{type:"schedule:skip",scope:"timer-group",timerId:t.id,reason:"overlap",...O,...G(w,b)});return}i.lastRunAt=O.scheduledAt,i.pending=!0,E(a.current.debug,{type:"schedule:start",scope:"timer-group",timerId:t.id,...O,...G(w,b)}),Promise.resolve().then(()=>r.callback(w,U(t.id),O)).then(()=>{E(a.current.debug,{type:"schedule:end",scope:"timer-group",timerId:t.id,...O,...G(w,b)})},x=>{E(a.current.debug,{type:"schedule:error",scope:"timer-group",timerId:t.id,error:x,...O,...G(w,b)})}).finally(()=>{n.current.get(t.id)?.state.generation===b&&(i.pending=!1)})},[U]),B=(0,l.useCallback)((t,r,u=!1)=>{let i=t.definition.schedules??[],w=new Set;i.forEach((b,O)=>{let x=b.id??String(O);w.add(x);let P=t.schedules.get(x);if(P||(P={lastRunAt:null,pending:!1,leadingGeneration:null},t.schedules.set(x,P)),u&&b.leading&&P.leadingGeneration!==t.state.generation){P.leadingGeneration=t.state.generation,$(t,b,x,P,r,t.state.generation,Se(b,x,r.now,r.now,0));return}P.lastRunAt===null&&(P.lastRunAt=t.state.startedAt??r.now);let ee=Math.floor((r.now-P.lastRunAt)/b.everyMs);if(ee>=1){let be=P.lastRunAt+ee*b.everyMs;$(t,b,x,P,r,t.state.generation,Se(b,x,be,r.now,ee-1))}});for(let b of t.schedules.keys())w.has(b)||t.schedules.delete(b)},[$]),k=(0,l.useCallback)((t,r=f(),u=!1)=>{if(t.state.status!=="running")return;let i=S(t.state,r);if(t.definition.endWhen?.(i)){if(le(t.state,r)){let w=S(t.state,r);c("timer:end",t,w),h(t,w)}return}B(t,i,u)},[h,c,B]),J=(0,l.useCallback)((t=f())=>{let u=a.current.updateIntervalMs??1e3;for(let i of n.current.values()){if(i.state.status!=="running")continue;(i.definition.schedules??[]).forEach((b,O)=>{let x=b.id??String(O),ee=i.schedules.get(x)?.lastRunAt??t.wallNow;u=Math.min(u,Math.max(1,ee+b.everyMs-t.wallNow))})}return u},[]),A=(0,l.useCallback)(t=>{let r=n.current.get(t.id);if(r)return r.definition=t,{item:r,added:!1};let u={id:t.id,state:ne(f()),definition:t,schedules:new Map,endCalledGeneration:null};return n.current.set(t.id,u),t.autoStart&&q(u.state,f()),{item:u,added:!0}},[]),Q=(0,l.useCallback)(()=>{let t=a.current.items??[],r=new Set,u=!1;t.forEach(i=>{r.add(i.id);let{item:w,added:b}=A(i);u=u||b,i.autoStart&&w.state.status==="idle"&&(u=q(w.state,f())||u)});for(let i of n.current.keys())r.has(i)||(n.current.delete(i),u=!0);return u},[A]);(0,l.useEffect)(()=>{Q()&&T()},[Q,e.items]);let V=(0,l.useCallback)(t=>{if(me([t]),n.current.has(t.id))throw new Error(`Timer item "${t.id}" already exists`);A(t),T()},[A]),X=(0,l.useCallback)((t,r)=>{let u=n.current.get(t);if(!u)return;let i={...u.definition,...r,id:t};me([i]),u.definition=i,T()},[]),Z=(0,l.useCallback)(t=>{n.current.delete(t),T()},[]),_=(0,l.useCallback)(()=>{n.current.clear(),v(),T()},[v]),H=(0,l.useCallback)(t=>{let r=n.current.get(t);if(!r)return;let u=f();q(r.state,u)&&(c("timer:start",r,S(r.state,u)),k(r,u,!0),T())},[c,k]),L=(0,l.useCallback)(t=>{let r=n.current.get(t);if(!r)return;let u=f();ue(r.state,u)&&(c("timer:pause",r,S(r.state,u)),T())},[c]),F=(0,l.useCallback)(t=>{let r=n.current.get(t);if(!r)return;let u=f();se(r.state,u)&&(c("timer:resume",r,S(r.state,u)),k(r,u,!0),T())},[c,k]),Y=(0,l.useCallback)((t,r={})=>{let u=n.current.get(t);if(!u)return;let i=f();te(u.state,i,r),u.schedules.clear(),u.endCalledGeneration=null,c("timer:reset",u,S(u.state,i)),r.autoStart&&k(u,i,!0),T()},[c,k]),z=(0,l.useCallback)(t=>{let r=n.current.get(t);if(!r)return;let u=f();ae(r.state,u),r.schedules.clear(),r.endCalledGeneration=null,c("timer:restart",r,S(r.state,u)),k(r,u,!0),T()},[c,k]),s=(0,l.useCallback)((t,r)=>{let u=n.current.get(t);if(!u)return;let i=f();ie(u.state,i,r)&&(c("timer:cancel",u,S(u.state,i),{reason:r}),T())},[c]),g=Array.from(n.current.keys()).map(t=>`${t}:${n.current.get(t).state.status}:${n.current.get(t).state.generation}:${n.current.get(t).state.tick}`).join("|");(0,l.useEffect)(()=>{y.current=!0;let t=Array.from(n.current.values()).filter(u=>u.state.status==="running");if(t.length===0){v();return}v();let r=t[0];return c("scheduler:start",r,S(r.state,f())),M.current=setTimeout(()=>{if(!y.current)return;let u=f();for(let i of n.current.values()){if(i.state.status!=="running")continue;ce(i.state,u);let w=S(i.state,u);c("timer:tick",i,w),k(i,u)}T()},J()),()=>{M.current!==null&&c("scheduler:stop",r,S(r.state,f())),v(),y.current=!1}},[g,v,c,J,k]);let R=(0,l.useCallback)(t=>{let r=n.current.get(t);if(r)return D(r)},[D]),C=f().wallNow,p=(0,l.useCallback)(()=>Array.from(n.current.keys()).forEach(H),[H]),K=(0,l.useCallback)(()=>Array.from(n.current.keys()).forEach(L),[L]),N=(0,l.useCallback)(()=>Array.from(n.current.keys()).forEach(F),[F]),I=(0,l.useCallback)(t=>Array.from(n.current.keys()).forEach(r=>Y(r,t)),[Y]),W=(0,l.useCallback)(()=>Array.from(n.current.keys()).forEach(z),[z]),re=(0,l.useCallback)(t=>Array.from(n.current.keys()).forEach(r=>s(r,t)),[s]);return(0,l.useMemo)(()=>({now:C,size:n.current.size,ids:Array.from(n.current.keys()),get:R,add:V,update:X,remove:Z,clear:_,start:H,pause:L,resume:F,reset:Y,restart:z,cancel:s,startAll:p,pauseAll:K,resumeAll:N,resetAll:I,restartAll:W,cancelAll:re}),[V,s,re,_,R,C,L,K,Z,Y,I,z,W,F,N,H,p,X])}function me(e){let o=new Set;e?.forEach(a=>{if(o.has(a.id))throw new Error(`Duplicate timer item id "${a.id}"`);o.add(a.id),a.schedules?.forEach(n=>j(n.everyMs,"schedule.everyMs"))})}function Se(e,o,a,n,y){return{scheduleId:e.id??o,scheduledAt:a,firedAt:n,nextRunAt:a+e.everyMs,overdueCount:y,effectiveEveryMs:e.everyMs}}0&&(module.exports={durationParts,useTimer,useTimerGroup});
@@ -0,0 +1,142 @@
1
+ type TimerStatus = 'idle' | 'running' | 'paused' | 'ended' | 'cancelled';
2
+ type DurationParts = {
3
+ totalMilliseconds: number;
4
+ totalSeconds: number;
5
+ milliseconds: number;
6
+ seconds: number;
7
+ minutes: number;
8
+ hours: number;
9
+ days: number;
10
+ };
11
+ type TimerSnapshot = {
12
+ status: TimerStatus;
13
+ now: number;
14
+ tick: number;
15
+ startedAt: number | null;
16
+ pausedAt: number | null;
17
+ endedAt: number | null;
18
+ cancelledAt: number | null;
19
+ cancelReason: string | null;
20
+ elapsedMilliseconds: number;
21
+ isIdle: boolean;
22
+ isRunning: boolean;
23
+ isPaused: boolean;
24
+ isEnded: boolean;
25
+ isCancelled: boolean;
26
+ };
27
+ type TimerControls = {
28
+ start(): void;
29
+ pause(): void;
30
+ resume(): void;
31
+ reset(options?: {
32
+ autoStart?: boolean;
33
+ }): void;
34
+ restart(): void;
35
+ cancel(reason?: string): void;
36
+ };
37
+ type TimerEndPredicate = (snapshot: TimerSnapshot) => boolean;
38
+ type TimerScheduleContext = {
39
+ scheduleId: string;
40
+ scheduledAt: number;
41
+ firedAt: number;
42
+ nextRunAt: number;
43
+ overdueCount: number;
44
+ effectiveEveryMs: number;
45
+ };
46
+ type TimerSchedule = {
47
+ id?: string;
48
+ everyMs: number;
49
+ leading?: boolean;
50
+ overlap?: 'skip' | 'allow';
51
+ callback: (snapshot: TimerSnapshot, controls: TimerControls, context: TimerScheduleContext) => void | Promise<void>;
52
+ };
53
+ type TimerDebug = boolean | TimerDebugLogger | {
54
+ enabled?: boolean;
55
+ logger?: TimerDebugLogger;
56
+ includeTicks?: boolean;
57
+ label?: string;
58
+ };
59
+ type TimerDebugLogger = (event: TimerDebugEvent) => void;
60
+ type TimerDebugEvent = {
61
+ type: 'timer:start' | 'timer:pause' | 'timer:resume' | 'timer:reset' | 'timer:restart' | 'timer:cancel' | 'timer:end' | 'timer:tick' | 'scheduler:start' | 'scheduler:stop' | 'schedule:start' | 'schedule:skip' | 'schedule:end' | 'schedule:error' | 'callback:error';
62
+ scope: 'timer' | 'timer-group';
63
+ label?: string;
64
+ timerId?: string;
65
+ scheduleId?: string;
66
+ generation: number;
67
+ tick: number;
68
+ now: number;
69
+ elapsedMilliseconds: number;
70
+ status: TimerStatus;
71
+ reason?: string;
72
+ error?: unknown;
73
+ scheduledAt?: number;
74
+ firedAt?: number;
75
+ nextRunAt?: number;
76
+ overdueCount?: number;
77
+ effectiveEveryMs?: number;
78
+ };
79
+ type UseTimerOptions = {
80
+ autoStart?: boolean;
81
+ updateIntervalMs?: number;
82
+ endWhen?: TimerEndPredicate;
83
+ onEnd?: (snapshot: TimerSnapshot, controls: TimerControls) => void | Promise<void>;
84
+ schedules?: TimerSchedule[];
85
+ debug?: TimerDebug;
86
+ };
87
+ type TimerGroupItemControls = {
88
+ start(): void;
89
+ pause(): void;
90
+ resume(): void;
91
+ reset(options?: {
92
+ autoStart?: boolean;
93
+ }): void;
94
+ restart(): void;
95
+ cancel(reason?: string): void;
96
+ };
97
+ type TimerGroupItem = {
98
+ id: string;
99
+ autoStart?: boolean;
100
+ endWhen?: TimerEndPredicate;
101
+ onEnd?: (snapshot: TimerSnapshot, controls: TimerGroupItemControls) => void | Promise<void>;
102
+ schedules?: TimerSchedule[];
103
+ };
104
+ type UseTimerGroupOptions = {
105
+ updateIntervalMs?: number;
106
+ items?: TimerGroupItem[];
107
+ debug?: TimerDebug;
108
+ };
109
+ type TimerGroupResult = {
110
+ now: number;
111
+ size: number;
112
+ ids: string[];
113
+ get(id: string): TimerSnapshot | undefined;
114
+ add(item: TimerGroupItem): void;
115
+ update(id: string, item: Partial<Omit<TimerGroupItem, 'id'>>): void;
116
+ remove(id: string): void;
117
+ clear(): void;
118
+ start(id: string): void;
119
+ pause(id: string): void;
120
+ resume(id: string): void;
121
+ reset(id: string, options?: {
122
+ autoStart?: boolean;
123
+ }): void;
124
+ restart(id: string): void;
125
+ cancel(id: string, reason?: string): void;
126
+ startAll(): void;
127
+ pauseAll(): void;
128
+ resumeAll(): void;
129
+ resetAll(options?: {
130
+ autoStart?: boolean;
131
+ }): void;
132
+ restartAll(): void;
133
+ cancelAll(reason?: string): void;
134
+ };
135
+
136
+ declare function durationParts(milliseconds: number): DurationParts;
137
+
138
+ declare function useTimer(options?: UseTimerOptions): TimerSnapshot & TimerControls;
139
+
140
+ declare function useTimerGroup(options?: UseTimerGroupOptions): TimerGroupResult;
141
+
142
+ export { type DurationParts, type TimerControls, type TimerDebug, type TimerDebugEvent, type TimerDebugLogger, type TimerEndPredicate, type TimerGroupItem, type TimerGroupItemControls, type TimerGroupResult, type TimerSchedule, type TimerScheduleContext, type TimerSnapshot, type TimerStatus, type UseTimerGroupOptions, type UseTimerOptions, durationParts, useTimer, useTimerGroup };
@@ -0,0 +1,142 @@
1
+ type TimerStatus = 'idle' | 'running' | 'paused' | 'ended' | 'cancelled';
2
+ type DurationParts = {
3
+ totalMilliseconds: number;
4
+ totalSeconds: number;
5
+ milliseconds: number;
6
+ seconds: number;
7
+ minutes: number;
8
+ hours: number;
9
+ days: number;
10
+ };
11
+ type TimerSnapshot = {
12
+ status: TimerStatus;
13
+ now: number;
14
+ tick: number;
15
+ startedAt: number | null;
16
+ pausedAt: number | null;
17
+ endedAt: number | null;
18
+ cancelledAt: number | null;
19
+ cancelReason: string | null;
20
+ elapsedMilliseconds: number;
21
+ isIdle: boolean;
22
+ isRunning: boolean;
23
+ isPaused: boolean;
24
+ isEnded: boolean;
25
+ isCancelled: boolean;
26
+ };
27
+ type TimerControls = {
28
+ start(): void;
29
+ pause(): void;
30
+ resume(): void;
31
+ reset(options?: {
32
+ autoStart?: boolean;
33
+ }): void;
34
+ restart(): void;
35
+ cancel(reason?: string): void;
36
+ };
37
+ type TimerEndPredicate = (snapshot: TimerSnapshot) => boolean;
38
+ type TimerScheduleContext = {
39
+ scheduleId: string;
40
+ scheduledAt: number;
41
+ firedAt: number;
42
+ nextRunAt: number;
43
+ overdueCount: number;
44
+ effectiveEveryMs: number;
45
+ };
46
+ type TimerSchedule = {
47
+ id?: string;
48
+ everyMs: number;
49
+ leading?: boolean;
50
+ overlap?: 'skip' | 'allow';
51
+ callback: (snapshot: TimerSnapshot, controls: TimerControls, context: TimerScheduleContext) => void | Promise<void>;
52
+ };
53
+ type TimerDebug = boolean | TimerDebugLogger | {
54
+ enabled?: boolean;
55
+ logger?: TimerDebugLogger;
56
+ includeTicks?: boolean;
57
+ label?: string;
58
+ };
59
+ type TimerDebugLogger = (event: TimerDebugEvent) => void;
60
+ type TimerDebugEvent = {
61
+ type: 'timer:start' | 'timer:pause' | 'timer:resume' | 'timer:reset' | 'timer:restart' | 'timer:cancel' | 'timer:end' | 'timer:tick' | 'scheduler:start' | 'scheduler:stop' | 'schedule:start' | 'schedule:skip' | 'schedule:end' | 'schedule:error' | 'callback:error';
62
+ scope: 'timer' | 'timer-group';
63
+ label?: string;
64
+ timerId?: string;
65
+ scheduleId?: string;
66
+ generation: number;
67
+ tick: number;
68
+ now: number;
69
+ elapsedMilliseconds: number;
70
+ status: TimerStatus;
71
+ reason?: string;
72
+ error?: unknown;
73
+ scheduledAt?: number;
74
+ firedAt?: number;
75
+ nextRunAt?: number;
76
+ overdueCount?: number;
77
+ effectiveEveryMs?: number;
78
+ };
79
+ type UseTimerOptions = {
80
+ autoStart?: boolean;
81
+ updateIntervalMs?: number;
82
+ endWhen?: TimerEndPredicate;
83
+ onEnd?: (snapshot: TimerSnapshot, controls: TimerControls) => void | Promise<void>;
84
+ schedules?: TimerSchedule[];
85
+ debug?: TimerDebug;
86
+ };
87
+ type TimerGroupItemControls = {
88
+ start(): void;
89
+ pause(): void;
90
+ resume(): void;
91
+ reset(options?: {
92
+ autoStart?: boolean;
93
+ }): void;
94
+ restart(): void;
95
+ cancel(reason?: string): void;
96
+ };
97
+ type TimerGroupItem = {
98
+ id: string;
99
+ autoStart?: boolean;
100
+ endWhen?: TimerEndPredicate;
101
+ onEnd?: (snapshot: TimerSnapshot, controls: TimerGroupItemControls) => void | Promise<void>;
102
+ schedules?: TimerSchedule[];
103
+ };
104
+ type UseTimerGroupOptions = {
105
+ updateIntervalMs?: number;
106
+ items?: TimerGroupItem[];
107
+ debug?: TimerDebug;
108
+ };
109
+ type TimerGroupResult = {
110
+ now: number;
111
+ size: number;
112
+ ids: string[];
113
+ get(id: string): TimerSnapshot | undefined;
114
+ add(item: TimerGroupItem): void;
115
+ update(id: string, item: Partial<Omit<TimerGroupItem, 'id'>>): void;
116
+ remove(id: string): void;
117
+ clear(): void;
118
+ start(id: string): void;
119
+ pause(id: string): void;
120
+ resume(id: string): void;
121
+ reset(id: string, options?: {
122
+ autoStart?: boolean;
123
+ }): void;
124
+ restart(id: string): void;
125
+ cancel(id: string, reason?: string): void;
126
+ startAll(): void;
127
+ pauseAll(): void;
128
+ resumeAll(): void;
129
+ resetAll(options?: {
130
+ autoStart?: boolean;
131
+ }): void;
132
+ restartAll(): void;
133
+ cancelAll(reason?: string): void;
134
+ };
135
+
136
+ declare function durationParts(milliseconds: number): DurationParts;
137
+
138
+ declare function useTimer(options?: UseTimerOptions): TimerSnapshot & TimerControls;
139
+
140
+ declare function useTimerGroup(options?: UseTimerGroupOptions): TimerGroupResult;
141
+
142
+ export { type DurationParts, type TimerControls, type TimerDebug, type TimerDebugEvent, type TimerDebugLogger, type TimerEndPredicate, type TimerGroupItem, type TimerGroupItemControls, type TimerGroupResult, type TimerSchedule, type TimerScheduleContext, type TimerSnapshot, type TimerStatus, type UseTimerGroupOptions, type UseTimerOptions, durationParts, useTimer, useTimerGroup };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ function he(e){let u=Math.max(0,Math.trunc(Number.isFinite(e)?e:0)),a=Math.floor(u/864e5),n=u%864e5,A=Math.floor(n/36e5),I=n%36e5,S=Math.floor(I/6e4),M=I%6e4,D=Math.floor(M/1e3);return{totalMilliseconds:u,totalSeconds:Math.floor(u/1e3),milliseconds:M%1e3,seconds:D,minutes:S,hours:A,days:a}}import{useCallback as G,useEffect as pe,useMemo as we,useReducer as Ae,useRef as j}from"react";function ye(e){return e?e===!0?{enabled:!0,includeTicks:!1,logger:console.debug}:typeof e=="function"?{enabled:!0,includeTicks:!1,logger:e}:{enabled:e.enabled!==!1,includeTicks:e.includeTicks??!1,label:e.label,logger:e.logger??console.debug}:{enabled:!1,includeTicks:!1}}function C(e,u){let a=ye(e);!a.enabled||!a.logger||u.type==="timer:tick"&&!a.includeTicks||a.logger({...u,label:u.label??a.label})}function E(e,u){return{generation:u,tick:e.tick,now:e.now,elapsedMilliseconds:e.elapsedMilliseconds,status:e.status}}function d(){let e=Date.now(),u=typeof performance<"u"&&typeof performance.now=="function"?performance.now():e;return{wallNow:e,monotonicNow:u}}function q(e,u){if(!Number.isFinite(e)||e<=0)throw new RangeError(`${u} must be a finite number greater than 0`)}function oe(e){return{status:"idle",generation:0,tick:0,startedAt:null,pausedAt:null,endedAt:null,cancelledAt:null,cancelReason:null,baseElapsedMilliseconds:0,activeStartedAtMonotonic:null,now:e.wallNow}}function ue(e,u){return e.status!=="running"||e.activeStartedAtMonotonic===null?e.baseElapsedMilliseconds:Math.max(0,e.baseElapsedMilliseconds+u.monotonicNow-e.activeStartedAtMonotonic)}function g(e,u){let a=ue(e,u);return{status:e.status,now:u.wallNow,tick:e.tick,startedAt:e.startedAt,pausedAt:e.pausedAt,endedAt:e.endedAt,cancelledAt:e.cancelledAt,cancelReason:e.cancelReason,elapsedMilliseconds:a,isIdle:e.status==="idle",isRunning:e.status==="running",isPaused:e.status==="paused",isEnded:e.status==="ended",isCancelled:e.status==="cancelled"}}function B(e,u){return e.status!=="idle"?!1:(e.status="running",e.startedAt=u.wallNow,e.pausedAt=null,e.endedAt=null,e.cancelledAt=null,e.cancelReason=null,e.activeStartedAtMonotonic=u.monotonicNow,e.now=u.wallNow,!0)}function se(e,u){return e.status!=="running"?!1:(e.baseElapsedMilliseconds=ue(e,u),e.activeStartedAtMonotonic=null,e.status="paused",e.pausedAt=u.wallNow,e.now=u.wallNow,!0)}function ae(e,u){return e.status!=="paused"?!1:(e.status="running",e.pausedAt=null,e.activeStartedAtMonotonic=u.monotonicNow,e.now=u.wallNow,!0)}function re(e,u,a={}){return e.generation+=1,e.tick=0,e.status=a.autoStart?"running":"idle",e.startedAt=a.autoStart?u.wallNow:null,e.pausedAt=null,e.endedAt=null,e.cancelledAt=null,e.cancelReason=null,e.baseElapsedMilliseconds=0,e.activeStartedAtMonotonic=a.autoStart?u.monotonicNow:null,e.now=u.wallNow,!0}function ie(e,u){return re(e,u,{autoStart:!0})}function le(e,u,a){return e.status==="ended"||e.status==="cancelled"?!1:(e.baseElapsedMilliseconds=ue(e,u),e.activeStartedAtMonotonic=null,e.status="cancelled",e.cancelledAt=u.wallNow,e.cancelReason=a??null,e.now=u.wallNow,!0)}function ce(e,u){return e.status!=="running"?!1:(e.baseElapsedMilliseconds=ue(e,u),e.activeStartedAtMonotonic=null,e.status="ended",e.endedAt=u.wallNow,e.now=u.wallNow,!0)}function de(e,u){return e.status!=="running"?!1:(e.tick+=1,e.now=u.wallNow,!0)}function Ie(e={}){let u=e.updateIntervalMs??1e3;q(u,"updateIntervalMs"),Me(e.schedules);let a=j(e);a.current=e;let n=j(null);n.current===null&&(n.current=oe(d()));let A=j(!1),I=j(null),S=j(new Map),M=j(null),[,D]=Ae(s=>s+1,0),l=G(()=>{I.current!==null&&(clearTimeout(I.current),I.current=null)},[]),U=G((s=d())=>g(n.current,s),[]),b=G((s,c,p={})=>{C(a.current.debug,{type:s,scope:"timer",...E(c,n.current.generation),...p})},[]),$=j(null),J=G(s=>{let c=n.current.generation;if(M.current!==c){M.current=c;try{a.current.onEnd?.(s,$.current)}catch(p){C(a.current.debug,{type:"callback:error",scope:"timer",...E(s,c),error:p})}}},[]),v=G((s,c,p,k,R,m)=>{if(p.pending&&(s.overlap??"skip")==="skip"){p.lastRunAt=m.scheduledAt,C(a.current.debug,{type:"schedule:skip",scope:"timer",reason:"overlap",...m,...E(k,R)});return}p.lastRunAt=m.scheduledAt,p.pending=!0,C(a.current.debug,{type:"schedule:start",scope:"timer",...m,...E(k,R)}),Promise.resolve().then(()=>s.callback(k,$.current,m)).then(()=>{C(a.current.debug,{type:"schedule:end",scope:"timer",...m,...E(k,R)})},K=>{C(a.current.debug,{type:"schedule:error",scope:"timer",error:K,...m,...E(k,R)})}).finally(()=>{n.current?.generation===R&&(p.pending=!1)})},[]),Q=G((s,c,p=!1)=>{let k=a.current.schedules??[],R=new Set;k.forEach((m,K)=>{let N=m.id??String(K);R.add(N);let w=S.current.get(N);if(w||(w={lastRunAt:null,pending:!1,leadingGeneration:null},S.current.set(N,w)),p&&m.leading&&w.leadingGeneration!==c){w.leadingGeneration=c,v(m,N,w,s,c,ge(m,N,s.now,s.now,0));return}w.lastRunAt===null&&(w.lastRunAt=n.current.startedAt??s.now);let W=Math.floor((s.now-w.lastRunAt)/m.everyMs);if(W>=1){let ne=w.lastRunAt+W*m.everyMs;v(m,N,w,s,c,ge(m,N,ne,s.now,W-1))}});for(let m of S.current.keys())R.has(m)||S.current.delete(m)},[v]),y=G((s=d(),c=!1)=>{let p=n.current;if(p.status!=="running")return;let k=g(p,s),R=p.generation;if(a.current.endWhen?.(k)){if(ce(p,s)){let m=g(p,s);b("timer:end",m),l(),J(m),D()}return}Q(k,R,c)},[J,l,b,Q]),V=G((s=d())=>{let c=a.current.updateIntervalMs??1e3,p=c;return n.current.status!=="running"?c:((a.current.schedules??[]).forEach((m,K)=>{let N=m.id??String(K),W=S.current.get(N)?.lastRunAt??s.wallNow;p=Math.min(p,Math.max(1,W+m.everyMs-s.wallNow))}),p)},[]),X=G(()=>{let s=d();if(!B(n.current,s))return;let c=g(n.current,s);b("timer:start",c),y(s,!0),D()},[b,y]),Z=G(()=>{let s=d();if(!se(n.current,s))return;l();let c=g(n.current,s);b("timer:pause",c),D()},[l,b]),_=G(()=>{let s=d();if(!ae(n.current,s))return;let c=g(n.current,s);b("timer:resume",c),y(s,!0),D()},[b,y]),ee=G((s={})=>{let c=d();l(),re(n.current,c,s),S.current.clear(),M.current=null;let p=g(n.current,c);b("timer:reset",p),s.autoStart&&y(c,!0),D()},[l,b,y]),H=G(()=>{let s=d();l(),ie(n.current,s),S.current.clear(),M.current=null;let c=g(n.current,s);b("timer:restart",c),y(s,!0),D()},[l,b,y]),L=G(s=>{let c=d();if(!le(n.current,c,s))return;l();let p=g(n.current,c);b("timer:cancel",p,{reason:s}),D()},[l,b]);$.current=we(()=>({start:X,pause:Z,resume:_,reset:ee,restart:H,cancel:L}),[L,Z,ee,H,_,X]),pe(()=>(A.current=!0,a.current.autoStart&&n.current.status==="idle"&&$.current.start(),()=>{A.current=!1,l()}),[l]);let F=U(),Y=n.current.generation,z=F.status;return pe(()=>{if(!A.current||z!=="running"){l();return}return l(),b("scheduler:start",U()),I.current=setTimeout(()=>{if(!A.current||n.current.generation!==Y||n.current.status!=="running")return;let s=d();de(n.current,s);let c=g(n.current,s);b("timer:tick",c),y(s),D()},V()),()=>{I.current!==null&&b("scheduler:stop",U()),l()}},[l,b,Y,V,U,y,F.tick,z]),{...F,...$.current}}function Me(e){e?.forEach(u=>q(u.everyMs,"schedule.everyMs"))}function ge(e,u,a,n,A){return{scheduleId:e.id??u,scheduledAt:a,firedAt:n,nextRunAt:a+e.everyMs,overdueCount:A,effectiveEveryMs:e.everyMs}}import{useCallback as f,useEffect as Se,useMemo as ve,useReducer as ke,useRef as me}from"react";function Re(e={}){let u=e.updateIntervalMs??1e3;q(u,"updateIntervalMs"),fe(e.items);let a=me(e);a.current=e;let n=me(new Map),A=me(!1),I=me(null),[,S]=ke(t=>t+1,0),M=f(()=>{I.current!==null&&(clearTimeout(I.current),I.current=null)},[]),D=f((t,r=d())=>g(t.state,r),[]),l=f((t,r,o,i={})=>{C(a.current.debug,{type:t,scope:"timer-group",timerId:r?.id,...E(o,r?.state.generation??0),...i})},[]),U=f(t=>({start:()=>H(t),pause:()=>L(t),resume:()=>F(t),reset:r=>Y(t,r),restart:()=>z(t),cancel:r=>s(t,r)}),[]),b=f((t,r)=>{let o=t.state.generation;if(t.endCalledGeneration!==o){t.endCalledGeneration=o;try{t.definition.onEnd?.(r,U(t.id))}catch(i){C(a.current.debug,{type:"callback:error",scope:"timer-group",timerId:t.id,error:i,...E(r,o)})}}},[U]),$=f((t,r,o,i,h,T,O)=>{if(i.pending&&(r.overlap??"skip")==="skip"){i.lastRunAt=O.scheduledAt,C(a.current.debug,{type:"schedule:skip",scope:"timer-group",timerId:t.id,reason:"overlap",...O,...E(h,T)});return}i.lastRunAt=O.scheduledAt,i.pending=!0,C(a.current.debug,{type:"schedule:start",scope:"timer-group",timerId:t.id,...O,...E(h,T)}),Promise.resolve().then(()=>r.callback(h,U(t.id),O)).then(()=>{C(a.current.debug,{type:"schedule:end",scope:"timer-group",timerId:t.id,...O,...E(h,T)})},x=>{C(a.current.debug,{type:"schedule:error",scope:"timer-group",timerId:t.id,error:x,...O,...E(h,T)})}).finally(()=>{n.current.get(t.id)?.state.generation===T&&(i.pending=!1)})},[U]),J=f((t,r,o=!1)=>{let i=t.definition.schedules??[],h=new Set;i.forEach((T,O)=>{let x=T.id??String(O);h.add(x);let P=t.schedules.get(x);if(P||(P={lastRunAt:null,pending:!1,leadingGeneration:null},t.schedules.set(x,P)),o&&T.leading&&P.leadingGeneration!==t.state.generation){P.leadingGeneration=t.state.generation,$(t,T,x,P,r,t.state.generation,Te(T,x,r.now,r.now,0));return}P.lastRunAt===null&&(P.lastRunAt=t.state.startedAt??r.now);let te=Math.floor((r.now-P.lastRunAt)/T.everyMs);if(te>=1){let be=P.lastRunAt+te*T.everyMs;$(t,T,x,P,r,t.state.generation,Te(T,x,be,r.now,te-1))}});for(let T of t.schedules.keys())h.has(T)||t.schedules.delete(T)},[$]),v=f((t,r=d(),o=!1)=>{if(t.state.status!=="running")return;let i=g(t.state,r);if(t.definition.endWhen?.(i)){if(ce(t.state,r)){let h=g(t.state,r);l("timer:end",t,h),b(t,h)}return}J(t,i,o)},[b,l,J]),Q=f((t=d())=>{let o=a.current.updateIntervalMs??1e3;for(let i of n.current.values()){if(i.state.status!=="running")continue;(i.definition.schedules??[]).forEach((T,O)=>{let x=T.id??String(O),te=i.schedules.get(x)?.lastRunAt??t.wallNow;o=Math.min(o,Math.max(1,te+T.everyMs-t.wallNow))})}return o},[]),y=f(t=>{let r=n.current.get(t.id);if(r)return r.definition=t,{item:r,added:!1};let o={id:t.id,state:oe(d()),definition:t,schedules:new Map,endCalledGeneration:null};return n.current.set(t.id,o),t.autoStart&&B(o.state,d()),{item:o,added:!0}},[]),V=f(()=>{let t=a.current.items??[],r=new Set,o=!1;t.forEach(i=>{r.add(i.id);let{item:h,added:T}=y(i);o=o||T,i.autoStart&&h.state.status==="idle"&&(o=B(h.state,d())||o)});for(let i of n.current.keys())r.has(i)||(n.current.delete(i),o=!0);return o},[y]);Se(()=>{V()&&S()},[V,e.items]);let X=f(t=>{if(fe([t]),n.current.has(t.id))throw new Error(`Timer item "${t.id}" already exists`);y(t),S()},[y]),Z=f((t,r)=>{let o=n.current.get(t);if(!o)return;let i={...o.definition,...r,id:t};fe([i]),o.definition=i,S()},[]),_=f(t=>{n.current.delete(t),S()},[]),ee=f(()=>{n.current.clear(),M(),S()},[M]),H=f(t=>{let r=n.current.get(t);if(!r)return;let o=d();B(r.state,o)&&(l("timer:start",r,g(r.state,o)),v(r,o,!0),S())},[l,v]),L=f(t=>{let r=n.current.get(t);if(!r)return;let o=d();se(r.state,o)&&(l("timer:pause",r,g(r.state,o)),S())},[l]),F=f(t=>{let r=n.current.get(t);if(!r)return;let o=d();ae(r.state,o)&&(l("timer:resume",r,g(r.state,o)),v(r,o,!0),S())},[l,v]),Y=f((t,r={})=>{let o=n.current.get(t);if(!o)return;let i=d();re(o.state,i,r),o.schedules.clear(),o.endCalledGeneration=null,l("timer:reset",o,g(o.state,i)),r.autoStart&&v(o,i,!0),S()},[l,v]),z=f(t=>{let r=n.current.get(t);if(!r)return;let o=d();ie(r.state,o),r.schedules.clear(),r.endCalledGeneration=null,l("timer:restart",r,g(r.state,o)),v(r,o,!0),S()},[l,v]),s=f((t,r)=>{let o=n.current.get(t);if(!o)return;let i=d();le(o.state,i,r)&&(l("timer:cancel",o,g(o.state,i),{reason:r}),S())},[l]),p=Array.from(n.current.keys()).map(t=>`${t}:${n.current.get(t).state.status}:${n.current.get(t).state.generation}:${n.current.get(t).state.tick}`).join("|");Se(()=>{A.current=!0;let t=Array.from(n.current.values()).filter(o=>o.state.status==="running");if(t.length===0){M();return}M();let r=t[0];return l("scheduler:start",r,g(r.state,d())),I.current=setTimeout(()=>{if(!A.current)return;let o=d();for(let i of n.current.values()){if(i.state.status!=="running")continue;de(i.state,o);let h=g(i.state,o);l("timer:tick",i,h),v(i,o)}S()},Q()),()=>{I.current!==null&&l("scheduler:stop",r,g(r.state,d())),M(),A.current=!1}},[p,M,l,Q,v]);let k=f(t=>{let r=n.current.get(t);if(r)return D(r)},[D]),R=d().wallNow,m=f(()=>Array.from(n.current.keys()).forEach(H),[H]),K=f(()=>Array.from(n.current.keys()).forEach(L),[L]),N=f(()=>Array.from(n.current.keys()).forEach(F),[F]),w=f(t=>Array.from(n.current.keys()).forEach(r=>Y(r,t)),[Y]),W=f(()=>Array.from(n.current.keys()).forEach(z),[z]),ne=f(t=>Array.from(n.current.keys()).forEach(r=>s(r,t)),[s]);return ve(()=>({now:R,size:n.current.size,ids:Array.from(n.current.keys()),get:k,add:X,update:Z,remove:_,clear:ee,start:H,pause:L,resume:F,reset:Y,restart:z,cancel:s,startAll:m,pauseAll:K,resumeAll:N,resetAll:w,restartAll:W,cancelAll:ne}),[X,s,ne,ee,k,R,L,K,_,Y,w,z,W,F,N,H,m,Z])}function fe(e){let u=new Set;e?.forEach(a=>{if(u.has(a.id))throw new Error(`Duplicate timer item id "${a.id}"`);u.add(a.id),a.schedules?.forEach(n=>q(n.everyMs,"schedule.everyMs"))})}function Te(e,u,a,n,A){return{scheduleId:e.id??u,scheduledAt:a,firedAt:n,nextRunAt:a+e.everyMs,overdueCount:A,effectiveEveryMs:e.everyMs}}export{he as durationParts,Ie as useTimer,Re as useTimerGroup};
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@crup/react-timer-hook",
3
+ "version": "0.0.1-alpha.10",
4
+ "description": "React timer lifecycle hooks for countdowns, stopwatches, schedules, and many independent timers.",
5
+ "main": "./dist/index.cjs",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.cjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "sideEffects": false,
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "tag": "alpha"
24
+ },
25
+ "engines": {
26
+ "node": ">=24.0.0"
27
+ },
28
+ "keywords": [
29
+ "react",
30
+ "hook",
31
+ "timer",
32
+ "stopwatch",
33
+ "time",
34
+ "countdown",
35
+ "scheduler",
36
+ "react-hooks"
37
+ ],
38
+ "author": "Rajender Joshi <connect@rajender.pro>",
39
+ "license": "MIT",
40
+ "type": "module",
41
+ "peerDependencies": {
42
+ "react": ">=18.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@changesets/cli": "^2.29.7",
46
+ "@commitlint/cli": "^20.2.0",
47
+ "@commitlint/config-conventional": "^20.2.0",
48
+ "@docusaurus/core": "^3.9.2",
49
+ "@docusaurus/module-type-aliases": "^3.9.2",
50
+ "@docusaurus/preset-classic": "^3.9.2",
51
+ "@docusaurus/tsconfig": "^3.9.2",
52
+ "@testing-library/react": "^16.3.0",
53
+ "@types/node": "^24.10.2",
54
+ "@types/react": "^19.2.7",
55
+ "@types/react-dom": "^19.2.3",
56
+ "@vitejs/plugin-react": "^5.1.2",
57
+ "husky": "^9.1.7",
58
+ "jsdom": "^27.3.0",
59
+ "prism-react-renderer": "^2.4.1",
60
+ "react": "^19.2.3",
61
+ "react-dom": "^19.2.3",
62
+ "tsup": "^8.5.1",
63
+ "typescript": "^5.9.3",
64
+ "vitest": "^4.0.16"
65
+ },
66
+ "scripts": {
67
+ "ai:context": "node scripts/ai-context.mjs",
68
+ "build": "tsup",
69
+ "changeset": "changeset",
70
+ "docs:build": "NO_UPDATE_NOTIFIER=1 docusaurus build docs-site",
71
+ "docs:dev": "NO_UPDATE_NOTIFIER=1 docusaurus start docs-site",
72
+ "docs:preview": "NO_UPDATE_NOTIFIER=1 docusaurus serve docs-site/build",
73
+ "mcp:docs": "node mcp/server.mjs",
74
+ "readme:check": "node scripts/check-readme.mjs",
75
+ "release": "changeset publish",
76
+ "size": "node scripts/size-report.mjs",
77
+ "size:json": "node scripts/size-report.mjs --json",
78
+ "test": "vitest run",
79
+ "test:watch": "vitest",
80
+ "typecheck": "tsc --noEmit"
81
+ }
82
+ }