Skip to content

Commit c3007a8

Browse files
committed
fix(timer): sync schedule updates and document use cases
1 parent 26c83a1 commit c3007a8

27 files changed

Lines changed: 596 additions & 54 deletions

README.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ Timers get messy when a product needs pause and resume, countdowns tied to serve
2020
`@crup/react-timer-hook` keeps the default import small and lets you add only the pieces your screen needs:
2121

2222
- ⏱️ `useTimer()` from the root package for one lifecycle: stopwatch, countdown, clock, or custom flow.
23-
- 🔋 Add-ons are opt-in: schedules, timer groups, duration helpers, and diagnostics live in subpath imports.
23+
- 🔋 Add schedules, timer groups, duration helpers, and diagnostics only when a screen needs them.
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-
- 🧪 Tested for React Strict Mode, rerenders, async callbacks, cleanup, and multi-timer screens.
27+
- 🧪 Covered for 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
@@ -51,6 +51,22 @@ Each recipe has a live playground and a focused code sample:
5151
- 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/)
5252
- 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/)
5353

54+
## Use cases
55+
56+
| Product case | Use | Import | Recipe |
57+
| --- | --- | --- | --- |
58+
| Stopwatch, call timer, workout timer | Core | `@crup/react-timer-hook` | [Stopwatch](https://crup.github.io/react-timer-hook/recipes/basic/stopwatch/) |
59+
| Wall clock or "last updated" display | Core | `@crup/react-timer-hook` | [Wall clock](https://crup.github.io/react-timer-hook/recipes/basic/wall-clock/) |
60+
| Auction, reservation, or job deadline | Core | `@crup/react-timer-hook` | [Absolute countdown](https://crup.github.io/react-timer-hook/recipes/basic/absolute-countdown/) |
61+
| 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/) |
62+
| Backend status polling | Schedules | `@crup/react-timer-hook/schedules` | [Polling schedule](https://crup.github.io/react-timer-hook/recipes/intermediate/polling-schedule/) |
63+
| 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/) |
64+
| 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/) |
65+
| 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/) |
67+
68+
See the full use-case guide: https://crup.github.io/react-timer-hook/use-cases/
69+
5470
## Quick examples
5571

5672
### Stopwatch
@@ -152,6 +168,7 @@ const timers = useTimerGroup({
152168
| `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. |
153169
| `endWhen` | `(snapshot) => boolean` | No | Ends the lifecycle when it returns `true`. Use this for countdowns, timeouts, and custom stop conditions. |
154170
| `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`. |
155172

156173
### `useScheduledTimer()` settings
157174

@@ -163,6 +180,7 @@ Import from `@crup/react-timer-hook/schedules` when you need polling or schedule
163180
| `updateIntervalMs` | `number` | No | Render/update cadence in milliseconds. Defaults to `1000`. Scheduled callbacks can run on their own cadence. |
164181
| `endWhen` | `(snapshot) => boolean` | No | Ends the lifecycle when it returns `true`. |
165182
| `onEnd` | `(snapshot, controls) => void \| Promise<void>` | No | Called once per generation when `endWhen` ends the lifecycle. |
183+
| `onError` | `(error, snapshot, controls) => void` | No | Handles sync throws and async rejections from `onEnd`. |
166184
| `schedules` | `TimerSchedule[]` | No | Scheduled side effects that run while the timer is active. Async overlap defaults to `skip`. |
167185
| `diagnostics` | `TimerDiagnostics` | No | Optional lifecycle and schedule events. No logs are emitted unless you pass a logger. |
168186

@@ -194,6 +212,7 @@ Import from `@crup/react-timer-hook/group` when many keyed items need independen
194212
| `autoStart` | `boolean` | No | Starts the item automatically when it is added or synced. Defaults to `false`. |
195213
| `endWhen` | `(snapshot) => boolean` | No | Ends that item when it returns `true`. |
196214
| `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`. |
197216
| `schedules` | `TimerSchedule[]` | No | Per-item schedules with the same contract as `useScheduledTimer()`. |
198217

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

228247
| Piece | Import | Best for | Raw | Gzip | Brotli |
229248
| --- | --- | --- | ---: | ---: | ---: |
230-
| ⏱️ Core | `@crup/react-timer-hook` | Stopwatch, countdown, clock, custom lifecycle | 3.82 kB | 1.31 kB | 1.21 kB |
231-
| 🧭 Timer group | `@crup/react-timer-hook/group` | Many independent row/item timers | 9.75 kB | 3.42 kB | 3.13 kB |
232-
| 📡 Schedules | `@crup/react-timer-hook/schedules` | Polling, cadence callbacks, overdue timing context | 7.41 kB | 2.57 kB | 2.36 kB |
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 |
233252
| 🧩 Duration | `@crup/react-timer-hook/duration` | `days`, `hours`, `minutes`, `seconds`, `milliseconds` | 318 B | 224 B | 192 B |
234253
| 🔎 Diagnostics | `@crup/react-timer-hook/diagnostics` | Optional lifecycle and schedule event logging | 105 B | 115 B | 90 B |
235254

docs-site/docs/api/types.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ description: Public TypeScript types exported by the package.
55

66
# Types
77

8+
## Lifecycle error callback
9+
10+
```ts
11+
onError?: (
12+
error: unknown,
13+
snapshot: TimerSnapshot,
14+
controls: TimerControls,
15+
) => void;
16+
```
17+
18+
Use `onError` when `onEnd` can throw or return a rejected promise.
19+
820
## Duration parts
921

1022
```ts

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +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`.
2223

2324
```ts
2425
const timer = useScheduledTimer({
@@ -37,3 +38,5 @@ const timer = useScheduledTimer({
3738
```
3839

3940
The third callback argument contains `scheduledAt`, `firedAt`, `nextRunAt`, `overdueCount`, and `effectiveEveryMs`.
41+
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.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ type TimerGroupItem = {
2727
autoStart?: boolean;
2828
endWhen?: (snapshot: TimerSnapshot) => boolean;
2929
onEnd?: (snapshot: TimerSnapshot, controls: TimerGroupItemControls) => void | Promise<void>;
30+
onError?: (error: unknown, snapshot: TimerSnapshot, controls: TimerGroupItemControls) => void;
3031
schedules?: TimerSchedule[];
3132
};
3233
```
3334

35+
`onError` receives sync throws and async rejections from that item's `onEnd`.
36+
37+
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.
38+
3439
## Result
3540

3641
```ts

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type UseTimerOptions = {
1717
updateIntervalMs?: number;
1818
endWhen?: (snapshot: TimerSnapshot) => boolean;
1919
onEnd?: (snapshot: TimerSnapshot, controls: TimerControls) => void | Promise<void>;
20+
onError?: (error: unknown, snapshot: TimerSnapshot, controls: TimerControls) => void;
2021
};
2122
```
2223

@@ -26,6 +27,8 @@ type UseTimerOptions = {
2627

2728
`onEnd` fires once per generation. `restart()` creates a new generation.
2829

30+
`onError` receives sync throws and async rejections from `onEnd`.
31+
2932
The root `useTimer()` export is lifecycle-only to keep the default bundle small. Use `@crup/react-timer-hook/schedules` when you need polling schedules and schedule timing context.
3033

3134
## Controls

docs-site/docs/getting-started.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Install the alpha package and wire a timer into a React component.
55

66
# Getting started
77

8-
Alpha builds are the only intended release channel until stable publishing is explicitly unlocked.
8+
Install the alpha build while the API is collecting feedback.
99

1010
```sh
1111
npm install @crup/react-timer-hook@alpha

docs-site/docs/index.mdx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ pnpm add @crup/react-timer-hook@alpha
3939
- `useTimerGroup()` from `/group` for many keyed independent lifecycles
4040
- `durationParts()` from `/duration` for duration display math
4141

42-
The library owns scheduling and cleanup. Your app owns formatting, timezone, data fetching policy, and business rules.
42+
Use the root hook first. Add subpaths only when a screen needs schedules, many independent timers, display parts, or diagnostics.
43+
44+
## Use-case shortcuts
45+
46+
- [Core use cases](./use-cases/core): stopwatch, wall clock, absolute deadline, pausable duration countdown.
47+
- [Schedule use cases](./use-cases/schedules): polling, heartbeat checks, timing context, early cancel.
48+
- [Timer group use cases](./use-cases/groups): auctions, checkout holds, upload rows, job dashboards.
49+
- [Composition guide](./use-cases/composition): combine core, duration, schedules, groups, and diagnostics.
4350

4451
## Recipe routes
4552

docs-site/docs/recipes/advanced/index.mdx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@ description: Many timers, independent group lifecycles, item polling, and runtim
77

88
Use these patterns for lists, dashboards, auctions, reservation holds, jobs, and task boards.
99

10-
- [Many display countdowns](./many-display-countdowns)
11-
- [Timer group](./timer-group)
12-
- [Group controls](./group-controls)
13-
- [Per-item polling](./per-item-polling)
14-
- [Dynamic items](./dynamic-items)
10+
## Core for display-only lists
1511

12+
- [Many display countdowns](./many-display-countdowns): one shared `useTimer()` drives many derived countdown labels.
13+
14+
## Timer group
15+
16+
- [Timer group](./timer-group): many keyed lifecycles with independent state.
17+
- [Group controls](./group-controls): pause, resume, restart, and cancel many items together.
18+
- [Dynamic items](./dynamic-items): add, update, and remove timers at runtime.
19+
20+
## Timer group + schedules
21+
22+
- [Per-item polling](./per-item-polling): each row owns its own schedule cadence.

docs-site/docs/recipes/basic/index.mdx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ description: Clocks, stopwatches, countdowns, and manual timer controls.
77

88
Start here when you need a single lifecycle or a display-only timer.
99

10-
- [Wall clock](./wall-clock)
11-
- [Stopwatch](./stopwatch)
12-
- [Absolute countdown](./absolute-countdown)
13-
- [Pausable countdown](./pausable-countdown)
14-
- [Manual controls](./manual-controls)
10+
## Core only
1511

12+
- [Wall clock](./wall-clock): use `timer.now` and format the `Date` in your app.
13+
- [Stopwatch](./stopwatch): use `elapsedMilliseconds` for active elapsed time.
14+
- [Absolute countdown](./absolute-countdown): derive remaining time from `expiresAt - timer.now`.
15+
- [Manual controls](./manual-controls): wire `start`, `pause`, `resume`, `reset`, `restart`, and `cancel`.
16+
17+
## Core + duration
18+
19+
- [Pausable countdown](./pausable-countdown): use `elapsedMilliseconds` and `durationParts()` for display math.

docs-site/docs/recipes/intermediate/index.mdx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@ description: End callbacks, schedules, early cancellation, backend events, and d
77

88
Use these patterns when the timer coordinates with side effects or external app state.
99

10-
- [Once-only onEnd](./once-only-on-end)
11-
- [Polling schedule](./polling-schedule)
12-
- [Poll and cancel](./poll-and-cancel)
13-
- [Backend event stop](./backend-event-stop)
14-
- [Diagnostics](./debug-logs)
10+
## Core lifecycle callbacks
11+
12+
- [Once-only onEnd](./once-only-on-end): run completion logic once per generation.
13+
- [Backend event stop](./backend-event-stop): cancel a timer from a WebSocket, SSE event, or external subscription.
14+
15+
## Schedules
16+
17+
- [Polling schedule](./polling-schedule): run cadence callbacks while the timer is active.
18+
- [Poll and cancel](./poll-and-cancel): stop early when backend state says the flow is closed.
19+
20+
## Diagnostics
21+
22+
- [Diagnostics](./debug-logs): emit optional lifecycle and schedule events while investigating behavior.

0 commit comments

Comments
 (0)