Skip to content

Commit 16b953b

Browse files
committed
fix(timer): harden schedules and expand recipes
1 parent c3007a8 commit 16b953b

41 files changed

Lines changed: 1024 additions & 75 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
File renamed without changes.
File renamed without changes.

‎.agents/plugins/marketplace.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"name": "caveman",
99
"source": {
1010
"source": "local",
11-
"path": "./plugins/caveman"
11+
"path": "./.agents/plugins/caveman"
1212
},
1313
"policy": {
1414
"installation": "AVAILABLE",
File renamed without changes.

‎README.md‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Timers get messy when a product needs pause and resume, countdowns tied to serve
2424
- 🧭 `useTimerGroup()` from `/group` for many keyed lifecycles with one shared scheduler.
2525
- 📡 `useScheduledTimer()` from `/schedules` for polling and timing context.
2626
- 🧩 `durationParts()` from `/duration` for common display math.
27-
- 🧪 Covered for rerenders, React Strict Mode, async callbacks, cleanup, and multi-timer screens.
27+
- 🧪 Tested against rerenders, React Strict Mode, async callbacks, cleanup, and multi-timer screens.
2828
- 🤖 AI-ready docs are available through hosted `llms.txt`, `llms-full.txt`, and an optional MCP docs helper.
2929

3030
## Install
@@ -36,6 +36,8 @@ npm install @crup/react-timer-hook@alpha
3636
pnpm add @crup/react-timer-hook@alpha
3737
```
3838

39+
Runtime requirements: Node 18+ and React 18+.
40+
3941
```tsx
4042
import { useTimer } from '@crup/react-timer-hook';
4143
import { durationParts } from '@crup/react-timer-hook/duration';
@@ -47,9 +49,9 @@ import { useScheduledTimer } from '@crup/react-timer-hook/schedules';
4749

4850
Each recipe has a live playground and a focused code sample:
4951

50-
- 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/)
51-
- 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/), [diagnostics](https://crup.github.io/react-timer-hook/recipes/intermediate/debug-logs/)
52-
- 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/)
52+
- 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/), [OTP resend cooldown](https://crup.github.io/react-timer-hook/recipes/basic/otp-resend/), [manual controls](https://crup.github.io/react-timer-hook/recipes/basic/manual-controls/)
53+
- 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/), [autosave heartbeat](https://crup.github.io/react-timer-hook/recipes/intermediate/autosave-heartbeat/), [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/), [diagnostics](https://crup.github.io/react-timer-hook/recipes/intermediate/debug-logs/)
54+
- 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/), [checkout holds](https://crup.github.io/react-timer-hook/recipes/advanced/checkout-holds/), [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/), [toast auto-dismiss](https://crup.github.io/react-timer-hook/recipes/advanced/toast-auto-dismiss/)
5355

5456
## Use cases
5557

@@ -59,14 +61,19 @@ Each recipe has a live playground and a focused code sample:
5961
| Wall clock or "last updated" display | Core | `@crup/react-timer-hook` | [Wall clock](https://crup.github.io/react-timer-hook/recipes/basic/wall-clock/) |
6062
| Auction, reservation, or job deadline | Core | `@crup/react-timer-hook` | [Absolute countdown](https://crup.github.io/react-timer-hook/recipes/basic/absolute-countdown/) |
6163
| Focus timer or checkout hold that pauses | Core + duration | `@crup/react-timer-hook` + `/duration` | [Pausable countdown](https://crup.github.io/react-timer-hook/recipes/basic/pausable-countdown/) |
64+
| OTP resend or retry cooldown | Core + duration | `@crup/react-timer-hook` + `/duration` | [OTP resend cooldown](https://crup.github.io/react-timer-hook/recipes/basic/otp-resend/) |
6265
| Backend status polling | Schedules | `@crup/react-timer-hook/schedules` | [Polling schedule](https://crup.github.io/react-timer-hook/recipes/intermediate/polling-schedule/) |
66+
| Draft autosave or presence heartbeat | Schedules | `@crup/react-timer-hook/schedules` | [Autosave heartbeat](https://crup.github.io/react-timer-hook/recipes/intermediate/autosave-heartbeat/) |
6367
| Polling that can close early | Schedules | `@crup/react-timer-hook/schedules` | [Poll and cancel](https://crup.github.io/react-timer-hook/recipes/intermediate/poll-and-cancel/) |
6468
| Auction list with independent row controls | Timer group | `@crup/react-timer-hook/group` | [Timer group](https://crup.github.io/react-timer-hook/recipes/advanced/timer-group/) |
69+
| Checkout holds with independent controls | Timer group | `@crup/react-timer-hook/group` | [Checkout holds](https://crup.github.io/react-timer-hook/recipes/advanced/checkout-holds/) |
6570
| Upload/job dashboard with per-row polling | Timer group + schedules | `@crup/react-timer-hook/group` | [Per-item polling](https://crup.github.io/react-timer-hook/recipes/advanced/per-item-polling/) |
66-
| Toast expiry or runtime item timers | Timer group | `@crup/react-timer-hook/group` | [Dynamic items](https://crup.github.io/react-timer-hook/recipes/advanced/dynamic-items/) |
71+
| Toast expiry or runtime item timers | Timer group | `@crup/react-timer-hook/group` | [Toast auto-dismiss](https://crup.github.io/react-timer-hook/recipes/advanced/toast-auto-dismiss/) |
6772

6873
See the full use-case guide: https://crup.github.io/react-timer-hook/use-cases/
6974

75+
Design assumptions and runtime limits: https://crup.github.io/react-timer-hook/project/caveats/
76+
7077
## Quick examples
7178

7279
### Stopwatch
@@ -168,7 +175,7 @@ const timers = useTimerGroup({
168175
| `updateIntervalMs` | `number` | No | Render/update cadence in milliseconds. Defaults to `1000`. This does not define elapsed time; elapsed time is calculated from timestamps. Use a smaller value like `100` or `20` when the UI needs finer updates. |
169176
| `endWhen` | `(snapshot) => boolean` | No | Ends the lifecycle when it returns `true`. Use this for countdowns, timeouts, and custom stop conditions. |
170177
| `onEnd` | `(snapshot, controls) => void \| Promise<void>` | No | Called once per generation when `endWhen` ends the lifecycle. `restart()` creates a new generation. |
171-
| `onError` | `(error, snapshot, controls) => void` | No | Handles sync throws and async rejections from `onEnd`. |
178+
| `onError` | `(error, snapshot, controls) => void` | No | Handles sync throws and async rejections from `onEnd`. Also used as the fallback for schedule callback failures when a schedule does not define `onError`. |
172179

173180
### `useScheduledTimer()` settings
174181

@@ -193,6 +200,7 @@ Import from `@crup/react-timer-hook/schedules` when you need polling or schedule
193200
| `leading` | `boolean` | No | Runs the schedule immediately when the timer starts or resumes into a new generation. Defaults to `false`. |
194201
| `overlap` | `'skip' \| 'allow'` | No | Controls async overlap. Defaults to `skip`, so a pending callback prevents another run. |
195202
| `callback` | `(snapshot, controls, context) => void \| Promise<void>` | Yes | Scheduled side effect. Receives timing context with `scheduledAt`, `firedAt`, `nextRunAt`, `overdueCount`, and `effectiveEveryMs`. |
203+
| `onError` | `(error, snapshot, controls, context) => void` | No | Handles sync throws and async rejections from that schedule's `callback`. Falls back to the timer or item `onError` when omitted. |
196204

197205
### `useTimerGroup()` settings
198206

@@ -212,7 +220,7 @@ Import from `@crup/react-timer-hook/group` when many keyed items need independen
212220
| `autoStart` | `boolean` | No | Starts the item automatically when it is added or synced. Defaults to `false`. |
213221
| `endWhen` | `(snapshot) => boolean` | No | Ends that item when it returns `true`. |
214222
| `onEnd` | `(snapshot, controls) => void \| Promise<void>` | No | Called once per item generation when that item ends naturally. |
215-
| `onError` | `(error, snapshot, controls) => void` | No | Handles sync throws and async rejections from that item's `onEnd`. |
223+
| `onError` | `(error, snapshot, controls) => void` | No | Handles sync throws and async rejections from that item's `onEnd`. Also used as the fallback for that item's schedule callback failures. |
216224
| `schedules` | `TimerSchedule[]` | No | Per-item schedules with the same contract as `useScheduledTimer()`. |
217225

218226
### Values and controls
@@ -246,9 +254,9 @@ The default import stays small. Add the other pieces only when that screen needs
246254

247255
| Piece | Import | Best for | Raw | Gzip | Brotli |
248256
| --- | --- | --- | ---: | ---: | ---: |
249-
| ⏱️ Core | `@crup/react-timer-hook` | Stopwatch, countdown, clock, custom lifecycle | 3.96 kB | 1.37 kB | 1.25 kB |
250-
| 🧭 Timer group | `@crup/react-timer-hook/group` | Many independent row/item timers | 9.91 kB | 3.51 kB | 3.20 kB |
251-
| 📡 Schedules | `@crup/react-timer-hook/schedules` | Polling, cadence callbacks, overdue timing context | 7.62 kB | 2.67 kB | 2.46 kB |
257+
| ⏱️ Core | `@crup/react-timer-hook` | Stopwatch, countdown, clock, custom lifecycle | 4.44 kB | 1.52 kB | 1.40 kB |
258+
| 🧭 Timer group | `@crup/react-timer-hook/group` | Many independent row/item timers | 10.93 kB | 3.83 kB | 3.50 kB |
259+
| 📡 Schedules | `@crup/react-timer-hook/schedules` | Polling, cadence callbacks, overdue timing context | 8.62 kB | 3.02 kB | 2.78 kB |
252260
| 🧩 Duration | `@crup/react-timer-hook/duration` | `days`, `hours`, `minutes`, `seconds`, `milliseconds` | 318 B | 224 B | 192 B |
253261
| 🔎 Diagnostics | `@crup/react-timer-hook/diagnostics` | Optional lifecycle and schedule event logging | 105 B | 115 B | 90 B |
254262

@@ -290,4 +298,4 @@ Issues, recipes, docs improvements, and focused bug reports are welcome.
290298
- Open an issue: https://github.com/crup/react-timer-hook/issues
291299
- See the contributing guide: ./CONTRIBUTING.md
292300

293-
The package targets Node 24 for development and React 18+ as a peer dependency.
301+
The package targets Node 18+ and React 18+.

‎docs-site/docs/api/types.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,19 @@ type TimerSchedule = {
5959
controls: TimerControls,
6060
context: TimerScheduleContext,
6161
) => void | Promise<void>;
62+
onError?: (
63+
error: unknown,
64+
snapshot: TimerSnapshot,
65+
controls: TimerControls,
66+
context: TimerScheduleContext,
67+
) => void;
6268
};
6369
```
6470

6571
`scheduledAt` is the intended fire time. `firedAt` is when the browser actually ran the callback. `overdueCount` reports how many schedule windows were missed before this callback because the browser, tab, or previous work delayed execution.
6672

73+
Schedule IDs must be unique within the same schedule list. If a schedule `callback` fails, schedule `onError` handles it. If schedule `onError` is omitted, the timer or timer-group item `onError` is used.
74+
6775
## Diagnostics
6876

6977
Diagnostics are opt-in. The package does not emit logs by default.

‎docs-site/docs/api/use-scheduled-timer.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type UseScheduledTimerOptions = UseTimerOptions & {
1919
```
2020

2121
Schedules run while active and default to `overlap: 'skip'`.
22-
`onError` comes from `UseTimerOptions` and receives sync throws or async rejections from `onEnd`.
22+
`onError` comes from `UseTimerOptions` and receives sync throws or async rejections from `onEnd`. It is also the fallback for schedule callback errors when a schedule does not define its own `onError`.
2323

2424
```ts
2525
const timer = useScheduledTimer({
@@ -32,11 +32,12 @@ const timer = useScheduledTimer({
3232
callback: async (snapshot, controls, context) => {
3333
console.log(context.overdueCount);
3434
},
35+
onError: error => reportPollError(error),
3536
},
3637
],
3738
});
3839
```
3940

4041
The third callback argument contains `scheduledAt`, `firedAt`, `nextRunAt`, `overdueCount`, and `effectiveEveryMs`.
4142

42-
When the schedule cadence changes, the active scheduler recalculates the next timeout immediately. Changing only the callback keeps the current cadence and uses the latest callback on the next run.
43+
When the schedule cadence changes, the active scheduler recalculates the next timeout after the render commits. Changing only the callback keeps the current cadence and uses the latest callback on the next run.

‎docs-site/docs/api/use-timer-group.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type TimerGroupItem = {
3232
};
3333
```
3434

35-
`onError` receives sync throws and async rejections from that item's `onEnd`.
35+
`onError` receives sync throws and async rejections from that item's `onEnd`. It also handles item schedule callback failures when a schedule does not define its own `onError`.
3636

3737
When `items` is controlled by props, pass a new array/object when item definitions change. The store preserves state for existing IDs and syncs new callbacks, deadlines, and schedules without resetting the item generation.
3838

‎docs-site/docs/getting-started.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ npm install @crup/react-timer-hook@alpha
1212
pnpm add @crup/react-timer-hook@alpha
1313
```
1414

15+
Runtime requirements: Node 18+ and React 18+.
16+
1517
```tsx
1618
import { useTimer } from '@crup/react-timer-hook';
1719
import { durationParts } from '@crup/react-timer-hook/duration';

‎docs-site/docs/index.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,8 @@ Use the root hook first. Add subpaths only when a screen needs schedules, many i
5353
- [Wall clock](./recipes/basic/wall-clock)
5454
- [Stopwatch](./recipes/basic/stopwatch)
5555
- [Auction countdown](./recipes/basic/absolute-countdown)
56+
- [OTP resend cooldown](./recipes/basic/otp-resend)
5657
- [Polling schedule](./recipes/intermediate/polling-schedule)
58+
- [Autosave heartbeat](./recipes/intermediate/autosave-heartbeat)
5759
- [Timer group](./recipes/advanced/timer-group)
60+
- [Toast auto-dismiss](./recipes/advanced/toast-auto-dismiss)

0 commit comments

Comments
 (0)