Skip to content

Commit 8513d6c

Browse files
committed
feat(schedules): expose timing context
1 parent fa622fe commit 8513d6c

File tree

12 files changed

+262
-35
lines changed

12 files changed

+262
-35
lines changed

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ const timer = useTimer({
108108
id: 'auction-poll',
109109
everyMs: 5000,
110110
overlap: 'skip',
111-
callback: async (_snapshot, controls) => {
111+
callback: async (_snapshot, controls, context) => {
112+
console.log(`auction poll fired ${context.firedAt - context.scheduledAt}ms late`);
112113
const auction = await api.getAuction(auctionId);
113114
if (auction.status === 'sold') controls.cancel('sold');
114115
},
@@ -139,9 +140,9 @@ Current build:
139140

140141
| File | Raw | Gzip | Brotli |
141142
| --- | ---: | ---: | ---: |
142-
| `dist/index.js` | 12.45 kB | 3.75 kB | 3.36 kB |
143-
| `dist/index.cjs` | 13.69 kB | 4.01 kB | 3.60 kB |
144-
| `dist/index.d.ts` | 3.95 kB | 992 B | 888 B |
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 |
145146

146147
CI writes a size summary to the GitHub Actions UI and posts bundle-size reports on pull requests.
147148

docs-site/docs/api/types.mdx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,30 @@ const parts = durationParts(90_500);
2828
## Schedules
2929

3030
```ts
31+
type TimerScheduleContext = {
32+
scheduleId: string;
33+
scheduledAt: number;
34+
firedAt: number;
35+
nextRunAt: number;
36+
overdueCount: number;
37+
effectiveEveryMs: number;
38+
};
39+
3140
type TimerSchedule = {
3241
id?: string;
3342
everyMs: number;
3443
leading?: boolean;
3544
overlap?: 'skip' | 'allow';
36-
callback: (snapshot: TimerSnapshot, controls: TimerControls) => void | Promise<void>;
45+
callback: (
46+
snapshot: TimerSnapshot,
47+
controls: TimerControls,
48+
context: TimerScheduleContext,
49+
) => void | Promise<void>;
3750
};
3851
```
3952

53+
`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.
54+
4055
## Debug
4156

4257
Debug is opt-in. The package does not emit logs by default.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ function useTimerGroup(options?: UseTimerGroupOptions): TimerGroupResult;
1111

1212
Use `useTimerGroup()` when every row needs independent pause, resume, cancel, restart, schedules, or `onEnd`.
1313

14+
Item schedules use the same `TimerSchedule` contract as `useTimer()`, including the third schedule context argument for intended fire time, actual fire time, next run time, and overdue interval count.
15+
1416
```ts
1517
type UseTimerGroupOptions = {
1618
updateIntervalMs?: number;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type UseTimerOptions = {
2828

2929
`onEnd` fires once per generation. `restart()` creates a new generation.
3030

31-
Schedules run while active and default to `overlap: 'skip'`.
31+
Schedules run while active and default to `overlap: 'skip'`. The schedule callback receives a third `context` argument with `scheduledAt`, `firedAt`, `nextRunAt`, `overdueCount`, and `effectiveEveryMs`, so delayed browser timers can be measured without exposing timeout handles.
3232

3333
## Controls
3434

docs-site/static/llms-full.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ Use `useTimerGroup()` for many keyed timers that each need independent lifecycle
3636

3737
Schedules are app-owned side effects. Default overlap behavior is `skip`.
3838

39+
Schedule callbacks receive `(snapshot, controls, context)`. `context` includes `scheduleId`, `scheduledAt`, `firedAt`, `nextRunAt`, `overdueCount`, and `effectiveEveryMs`.
40+
3941
## Recipes index
4042

4143
Basic:

docs-site/static/llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ Core rules:
1818
- Use endWhen(snapshot) to end a lifecycle.
1919
- Use cancel(reason) for terminal early stops.
2020
- Schedules run while active and default to overlap: 'skip'.
21+
- Schedule callbacks receive context with scheduledAt, firedAt, nextRunAt, overdueCount, and effectiveEveryMs.
2122
- Debug logs are opt-in only.
2223
- Formatting, locale, timezone, caching, retries, and business rules are userland.

src/__tests__/useTimer.schedules.test.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,89 @@ describe('useTimer schedules', () => {
132132

133133
expect(callback).toHaveBeenCalledTimes(1);
134134
});
135+
136+
it('passes schedule timing context to callbacks', async () => {
137+
const callback = vi.fn();
138+
renderHook(() =>
139+
useTimer({
140+
autoStart: true,
141+
updateIntervalMs: 100,
142+
schedules: [{ id: 'poll', everyMs: 100, callback }],
143+
}),
144+
);
145+
146+
act(() => vi.advanceTimersByTime(100));
147+
await act(async () => {});
148+
149+
expect(callback).toHaveBeenCalledWith(
150+
expect.objectContaining({ now: 100 }),
151+
expect.objectContaining({ cancel: expect.any(Function) }),
152+
{
153+
scheduleId: 'poll',
154+
scheduledAt: 100,
155+
firedAt: 100,
156+
nextRunAt: 200,
157+
overdueCount: 0,
158+
effectiveEveryMs: 100,
159+
},
160+
);
161+
});
162+
163+
it('reports overdue intervals when a scheduled timeout fires late', async () => {
164+
const callback = vi.fn();
165+
renderHook(() =>
166+
useTimer({
167+
autoStart: true,
168+
updateIntervalMs: 1000,
169+
schedules: [{ id: 'poll', everyMs: 100, leading: true, callback }],
170+
}),
171+
);
172+
173+
await act(async () => {});
174+
callback.mockClear();
175+
176+
act(() => vi.setSystemTime(350));
177+
act(() => vi.advanceTimersByTime(100));
178+
await act(async () => {});
179+
180+
expect(callback).toHaveBeenCalledWith(
181+
expect.objectContaining({ now: 450 }),
182+
expect.anything(),
183+
{
184+
scheduleId: 'poll',
185+
scheduledAt: 400,
186+
firedAt: 450,
187+
nextRunAt: 500,
188+
overdueCount: 3,
189+
effectiveEveryMs: 100,
190+
},
191+
);
192+
});
193+
194+
it('emits schedule timing context in debug logs', async () => {
195+
const logger = vi.fn();
196+
renderHook(() =>
197+
useTimer({
198+
autoStart: true,
199+
updateIntervalMs: 100,
200+
debug: { logger },
201+
schedules: [{ id: 'poll', everyMs: 100, callback: vi.fn() }],
202+
}),
203+
);
204+
205+
act(() => vi.advanceTimersByTime(100));
206+
await act(async () => {});
207+
208+
expect(logger).toHaveBeenCalledWith(
209+
expect.objectContaining({
210+
type: 'schedule:start',
211+
scheduleId: 'poll',
212+
scheduledAt: 100,
213+
firedAt: 100,
214+
nextRunAt: 200,
215+
overdueCount: 0,
216+
effectiveEveryMs: 100,
217+
}),
218+
);
219+
});
135220
});

src/__tests__/useTimerGroup.schedules.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,52 @@ describe('useTimerGroup schedules and debug', () => {
3636
expect(logger).toHaveBeenCalledWith(expect.objectContaining({ type: 'schedule:start', timerId: 'a', scheduleId: 'poll' }));
3737
});
3838

39+
it('passes schedule timing context to item callbacks and debug logs', async () => {
40+
const callback = vi.fn();
41+
const logger = vi.fn();
42+
renderHook(() =>
43+
useTimerGroup({
44+
updateIntervalMs: 100,
45+
debug: { logger },
46+
items: [
47+
{
48+
id: 'a',
49+
autoStart: true,
50+
schedules: [{ id: 'poll', everyMs: 100, callback }],
51+
},
52+
],
53+
}),
54+
);
55+
56+
act(() => vi.advanceTimersByTime(100));
57+
await act(async () => {});
58+
59+
expect(callback).toHaveBeenCalledWith(
60+
expect.objectContaining({ now: 100 }),
61+
expect.objectContaining({ cancel: expect.any(Function) }),
62+
{
63+
scheduleId: 'poll',
64+
scheduledAt: 100,
65+
firedAt: 100,
66+
nextRunAt: 200,
67+
overdueCount: 0,
68+
effectiveEveryMs: 100,
69+
},
70+
);
71+
expect(logger).toHaveBeenCalledWith(
72+
expect.objectContaining({
73+
type: 'schedule:start',
74+
timerId: 'a',
75+
scheduleId: 'poll',
76+
scheduledAt: 100,
77+
firedAt: 100,
78+
nextRunAt: 200,
79+
overdueCount: 0,
80+
effectiveEveryMs: 100,
81+
}),
82+
);
83+
});
84+
3985
it('drives many timers with one cadence', () => {
4086
const items = Array.from({ length: 100 }, (_, index) => ({ id: String(index), autoStart: true }));
4187
const { result } = renderHook(() => useTimerGroup({ updateIntervalMs: 100, items }));

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type {
1313
TimerGroupItemControls,
1414
TimerGroupResult,
1515
TimerSchedule,
16+
TimerScheduleContext,
1617
TimerSnapshot,
1718
TimerStatus,
1819
UseTimerGroupOptions,

src/types.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,21 @@ export type TimerControls = {
3838

3939
export type TimerEndPredicate = (snapshot: TimerSnapshot) => boolean;
4040

41+
export type TimerScheduleContext = {
42+
scheduleId: string;
43+
scheduledAt: number;
44+
firedAt: number;
45+
nextRunAt: number;
46+
overdueCount: number;
47+
effectiveEveryMs: number;
48+
};
49+
4150
export type TimerSchedule = {
4251
id?: string;
4352
everyMs: number;
4453
leading?: boolean;
4554
overlap?: 'skip' | 'allow';
46-
callback: (snapshot: TimerSnapshot, controls: TimerControls) => void | Promise<void>;
55+
callback: (snapshot: TimerSnapshot, controls: TimerControls, context: TimerScheduleContext) => void | Promise<void>;
4756
};
4857

4958
export type TimerDebug =
@@ -86,6 +95,11 @@ export type TimerDebugEvent = {
8695
status: TimerStatus;
8796
reason?: string;
8897
error?: unknown;
98+
scheduledAt?: number;
99+
firedAt?: number;
100+
nextRunAt?: number;
101+
overdueCount?: number;
102+
effectiveEveryMs?: number;
89103
};
90104

91105
export type UseTimerOptions = {

0 commit comments

Comments
 (0)