@zerotal/scheduler 1.9.0 → 1.11.0

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/CHANGELOG.md CHANGED
@@ -8,6 +8,53 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.10.0] — 2026-08-30
12
+
13
+ ### Fixed
14
+
15
+ - **A task with a `timezone` no longer takes the whole scheduler down.** The code
16
+ passed croner's options form — `Bun.cron(schedule, { run, timezone })` — on the
17
+ belief that Bun accepted it at runtime and only the types disagreed. It does not:
18
+ it throws, and it throws during _registration_, so the worker died on boot and
19
+ restart-looped. One task with a timezone stopped **every** task in the app,
20
+ including the ones that release inventory holds and chase deposits. Setting a
21
+ timezone is the obvious thing to do for a business that operates in one country and
22
+ is not on UTC, which is most of them.
23
+
24
+ Zerotal evaluates the zone itself now. A zoned task registers a minute tick and runs
25
+ on the ticks where its expression matches the wall clock in its own zone — minute
26
+ granularity being the finest `Bun.cron` accepts anyway, so nothing is given up, and
27
+ comparing against the zone's local time rather than an offset computed once is what
28
+ keeps it right across a daylight-saving change. `nextRunAt()` reads the same zone,
29
+ so `schedule:list` and the monitor agree with when the task will actually run.
30
+
31
+ The test that covered this asserted the broken behaviour against a mock that
32
+ accepted anything. It now runs against the real `Bun.cron`.
33
+
34
+ - **A task that cannot register takes only itself out.** Registration throws for
35
+ exactly the reasons that are one task's problem — an expression the runtime
36
+ rejects, a zone it does not know — and letting one propagate meant a typo in one
37
+ schedule stopped every schedule in the app, from a crash loop that named none of
38
+ them. The others start now, and the log names the one that did not.
39
+
40
+ ### Changed
41
+
42
+ - **`scheduler.timezone` does something.** It was documented as informational and read
43
+ by nothing. It is now the zone every schedule is evaluated in unless the task sets
44
+ its own — one line instead of a `.timezone()` on every task. Its default changed
45
+ from the literal `"UTC"` to **the system zone**, so an app that never set the key
46
+ keeps doing exactly what it did; only an app that explicitly asked for a zone gets
47
+ a change, which is the change it asked for.
48
+
49
+ ### Added
50
+
51
+ - `UnknownTimeZoneError` (and the `SchedulerError` base it extends) — raised at
52
+ registration and naming the task, rather than a `RangeError` thrown once a minute
53
+ from inside a cron tick with nothing to say which schedule it came from.
54
+ - `wallClockIn`, `isValidTimeZone`, `CronExpression.matchesIn` and
55
+ `CronExpression.nextRunAfterIn` — the zone arithmetic the scheduler now uses,
56
+ exported because an app doing its own time-window logic needs the same answers.
57
+
11
58
  ## [1.9.0] — 2026-08-29
12
59
 
13
60
  ### Documented
package/api-surface.md CHANGED
@@ -10,10 +10,12 @@ class CronExpression = {
10
10
  static describe: (expression: string) => string
11
11
  static isValid: (expression: string) => boolean
12
12
  static nextRunAfter: (expression: string, from?: Date) => Date | null
13
+ static nextRunAfterIn: (expression: string, from: Date, timeZone: string) => Date | null
13
14
  static readonly SEARCH_HORIZON_DAYS: number
14
15
  dayOfMonth: (v: number | string) => CronExpression
15
16
  hour: (v: number | string) => CronExpression
16
17
  matches: (date?: Date) => boolean
18
+ matchesIn: (date: Date, timeZone: string) => boolean
17
19
  minute: (v: number | string) => CronExpression
18
20
  month: (v: number | string) => CronExpression
19
21
  nextRun: (from?: Date) => Date | null
@@ -121,6 +123,13 @@ class SchedulerBuilder = {
121
123
  yearlyOn: (month?: number, dayOfMonth?: number, time?: string) => ScheduledTask
122
124
  }
123
125
 
126
+ class SchedulerError = {
127
+ new (message: string, code?: string, status?: number, context?: Record<string, unknown>): SchedulerError
128
+ readonly code: string
129
+ readonly context?: Record<string, unknown> | undefined
130
+ readonly status: number
131
+ }
132
+
124
133
  class SchedulerManager = {
125
134
  new (): SchedulerManager
126
135
  add: (name: string, cronExpression: string, callback: TaskCallback) => ScheduledTask
@@ -172,14 +181,25 @@ class TaskSkipped = {
172
181
  readonly reason: 'lock' | 'window' | 'env' | 'when' | 'skip' | 'overlap'
173
182
  }
174
183
 
184
+ class UnknownTimeZoneError = {
185
+ new (task: string, timezone: string): UnknownTimeZoneError
186
+ readonly code: string
187
+ readonly context?: Record<string, unknown> | undefined
188
+ readonly status: number
189
+ }
190
+
175
191
  const Scheduler = SchedulerManager
176
192
 
193
+ function isValidTimeZone = (timeZone: string) => boolean
194
+
177
195
  function SchedulerConfig = (options?: Partial<SchedulerConfigShape>) => SchedulerConfigShape
178
196
 
197
+ function wallClockIn = (date: Date, timeZone: string) => Date
198
+
179
199
  interface OverlapLockOptions = {
180
- crossProcess?: boolean
181
- expiresAfterMinutes?: number
182
- refresh?: boolean
200
+ crossProcess?: boolean | undefined
201
+ expiresAfterMinutes?: number | undefined
202
+ refresh?: boolean | undefined
183
203
  }
184
204
 
185
205
  interface RunLogConfig = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/scheduler",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,7 +30,7 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.9.0"
33
+ "@zerotal/core": "1.11.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.8.0"
@@ -6,6 +6,73 @@
6
6
  * Field syntax: star, n, a-b, a-b with step, star with step, and
7
7
  * comma-separated lists of any of those (e.g. `1,15,30` or `1-5`).
8
8
  */
9
+ /**
10
+ * The same instant, shifted so a `Date`'s **local** getters read `timeZone`'s wall
11
+ * clock.
12
+ *
13
+ * Cron is a wall-clock spec: `0 3 * * *` means three in the morning where the
14
+ * business is, not three UTC. Every matcher below reads `getHours()`,
15
+ * `getMinutes()` and friends, which answer in the *process's* zone — so evaluating
16
+ * a schedule in another zone is a question of which Date you hand them.
17
+ *
18
+ * The returned Date is a lie about the instant and true about the clock face: its
19
+ * epoch value is wrong by the zone offset, and it is only ever passed to the field
20
+ * matchers, never returned to a caller or compared against a real time.
21
+ *
22
+ * @param date - The instant to read.
23
+ * @param timeZone - An IANA zone name, e.g. `"Africa/Johannesburg"`.
24
+ * @returns A Date whose local fields are that zone's wall clock at `date`.
25
+ * @throws {@link RangeError} When `timeZone` is not a zone this runtime knows.
26
+ */
27
+ export function wallClockIn(date: Date, timeZone: string): Date {
28
+ const parts = new Intl.DateTimeFormat("en-US", {
29
+ timeZone,
30
+ hourCycle: "h23",
31
+ year: "numeric",
32
+ month: "2-digit",
33
+ day: "2-digit",
34
+ hour: "2-digit",
35
+ minute: "2-digit",
36
+ second: "2-digit",
37
+ }).formatToParts(date);
38
+
39
+ const field = (type: string): number => {
40
+ const found = parts.find((p) => p.type === type);
41
+ return found ? Number(found.value) : 0;
42
+ };
43
+
44
+ return new Date(
45
+ field("year"),
46
+ field("month") - 1,
47
+ field("day"),
48
+ field("hour"),
49
+ field("minute"),
50
+ field("second"),
51
+ 0,
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Whether this runtime knows `timeZone`.
57
+ *
58
+ * Worth checking separately from using it: a typo in a schedule's zone would
59
+ * otherwise surface as a `RangeError` thrown once a minute from inside a cron
60
+ * tick, with nothing naming the task it came from.
61
+ *
62
+ * @param timeZone - An IANA zone name.
63
+ */
64
+ export function isValidTimeZone(timeZone: string): boolean {
65
+ try {
66
+ new Intl.DateTimeFormat("en-US", { timeZone });
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /** Minutes in a day, for the day-skip in the next-run scans. */
74
+ const MINUTES_PER_DAY = 24 * 60;
75
+
9
76
  export class CronExpression {
10
77
  private fields: [string, string, string, string, string];
11
78
 
@@ -148,6 +215,17 @@ export class CronExpression {
148
215
  );
149
216
  }
150
217
 
218
+ /**
219
+ * Whether the expression matches `date` **as read in `timeZone`**.
220
+ *
221
+ * @param date - The instant to test.
222
+ * @param timeZone - An IANA zone name the expression is written against.
223
+ * @throws {@link RangeError} When `timeZone` is not a zone this runtime knows.
224
+ */
225
+ matchesIn(date: Date, timeZone: string): boolean {
226
+ return this.matches(wallClockIn(date, timeZone));
227
+ }
228
+
151
229
  nextRun(from: Date = new Date()): Date | null {
152
230
  return CronExpression.nextRunAfter(this.toString(), from);
153
231
  }
@@ -226,6 +304,50 @@ export class CronExpression {
226
304
  return null;
227
305
  }
228
306
 
307
+ /**
308
+ * The first instant at or after `from` that `expression` fires, reading the clock
309
+ * in `timeZone`.
310
+ *
311
+ * The returned Date is a real instant, not a shifted one — the shifting happens
312
+ * inside the scan, where the expression is compared against the zone's wall clock
313
+ * a minute at a time. That is what keeps it right across a DST change: nothing
314
+ * here computes an offset once and adds it.
315
+ *
316
+ * @param expression - A 5- or 6-field cron expression.
317
+ * @param from - Search start; the result is strictly after this minute.
318
+ * @param timeZone - An IANA zone name the expression is written against.
319
+ * @returns The next firing instant, or `null` for an invalid expression, an unknown
320
+ * zone, or no occurrence within {@link SEARCH_HORIZON_DAYS}.
321
+ */
322
+ static nextRunAfterIn(expression: string, from: Date, timeZone: string): Date | null {
323
+ if (!CronExpression.isValid(expression)) return null;
324
+ if (!isValidTimeZone(timeZone)) return null;
325
+
326
+ const cron = new CronExpression(expression);
327
+ const cursor = new Date(from);
328
+ cursor.setSeconds(0, 0);
329
+ cursor.setMinutes(cursor.getMinutes() + 1);
330
+
331
+ const deadline = new Date(from);
332
+ deadline.setDate(deadline.getDate() + CronExpression.SEARCH_HORIZON_DAYS);
333
+
334
+ while (cursor <= deadline) {
335
+ const local = wallClockIn(cursor, timeZone);
336
+ if (cron._dayMatches(local)) {
337
+ if (cron.matches(local)) return new Date(cursor);
338
+ cursor.setMinutes(cursor.getMinutes() + 1);
339
+ } else {
340
+ // Nothing on this local day can match — jump to the zone's next midnight
341
+ // rather than probing 1,440 minutes. Zone offsets are whole minutes, so
342
+ // this lands on it exactly; a DST change costs an hour of extra probes and
343
+ // nothing else.
344
+ const elapsed = local.getHours() * 60 + local.getMinutes();
345
+ cursor.setMinutes(cursor.getMinutes() + (MINUTES_PER_DAY - elapsed));
346
+ }
347
+ }
348
+ return null;
349
+ }
350
+
229
351
  /**
230
352
  * How far ahead {@link nextRunAfter} looks. Four years and change, so a Feb-29 expression
231
353
  * resolves to the next leap day instead of reporting that it never runs.
@@ -1,11 +1,22 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { FrameworkEvents } from "@zerotal/core";
2
+ import { config, FrameworkEvents } from "@zerotal/core";
3
3
  import { deployEnv } from "@zerotal/core";
4
4
  import { TaskRan, TaskFailed, TaskSkipped } from "./events.ts";
5
5
  import type { LockManager, ManagedLock } from "@zerotal/core/lock";
6
- import { CronExpression } from "./CronExpression.ts";
6
+ import { CronExpression, isValidTimeZone } from "./CronExpression.ts";
7
+ import { UnknownTimeZoneError } from "./errors.ts";
7
8
  import { frameworkLog } from "@zerotal/core/logger";
8
9
 
10
+ /**
11
+ * The tick a timezoned task rides on.
12
+ *
13
+ * `Bun.cron` evaluates a schedule in the system zone and offers no way to change
14
+ * that, so a task with its own zone registers this instead and does the matching
15
+ * itself. Minute granularity is not a compromise: five fields is all `Bun.cron`
16
+ * accepts, so a minute is the finest a Zerotal schedule can express anyway.
17
+ */
18
+ const EVERY_MINUTE = "* * * * *";
19
+
9
20
  // ── Output capture (shared, reference-counted) ────────────────────────────────
10
21
  // A single `console.log` patch is installed while ANY task is capturing, and the
11
22
  // captured output is routed to the running task's own buffer via AsyncLocalStorage.
@@ -47,7 +58,7 @@ export interface OverlapLockOptions {
47
58
  * Take a cross-process lock (via the configured lock driver) in addition to the
48
59
  * in-process guard. Default: `true`. Set `false` to guard within this process only.
49
60
  */
50
- crossProcess?: boolean;
61
+ crossProcess?: boolean | undefined;
51
62
 
52
63
  /**
53
64
  * **How long after a crash before another host may take the task over**, in
@@ -64,7 +75,7 @@ export interface OverlapLockOptions {
64
75
  * want a crash to be *slower* to recover from, which is rarely what anyone
65
76
  * wants.
66
77
  */
67
- expiresAfterMinutes?: number;
78
+ expiresAfterMinutes?: number | undefined;
68
79
 
69
80
  /**
70
81
  * Heartbeat the lock for as long as the task runs. Default: `true`.
@@ -73,7 +84,7 @@ export interface OverlapLockOptions {
73
84
  * inside {@link expiresAfterMinutes} or lose its lock — and where you must
74
85
  * therefore size that value for the worst case.
75
86
  */
76
- refresh?: boolean;
87
+ refresh?: boolean | undefined;
77
88
  }
78
89
 
79
90
  export class ScheduledTask {
@@ -134,6 +145,14 @@ export class ScheduledTask {
134
145
  this._callback = callback;
135
146
  }
136
147
 
148
+ /**
149
+ * Evaluate this task's schedule in `tz` rather than the server's zone.
150
+ *
151
+ * @param tz - An IANA zone name, e.g. `"Africa/Johannesburg"`.
152
+ * @throws {@link UnknownTimeZoneError} At {@link start}, when the runtime does not
153
+ * know the zone — named there rather than here so a schedule declared as data
154
+ * fails where the failure can name the task.
155
+ */
137
156
  timezone(tz: string): this {
138
157
  this._timezone = tz;
139
158
  return this;
@@ -250,9 +269,18 @@ export class ScheduledTask {
250
269
  get lastDurationMs(): number | undefined {
251
270
  return this._lastDurationMs;
252
271
  }
253
- /** Next scheduled fire time from `from` (null if the expression never matches). */
272
+ /**
273
+ * Next scheduled fire time from `from` (null if the expression never matches).
274
+ *
275
+ * Read in the task's own timezone when it has one, so `schedule:list` and the
276
+ * monitor agree with when the task will actually run rather than with when the
277
+ * same expression would fire in the server's zone.
278
+ */
254
279
  nextRunAt(from: Date = new Date()): Date | null {
255
- return new CronExpression(this._schedule).nextRun(from);
280
+ const timezone = this._effectiveTimezone();
281
+ return timezone
282
+ ? CronExpression.nextRunAfterIn(this._schedule, from, timezone)
283
+ : new CronExpression(this._schedule).nextRun(from);
256
284
  }
257
285
  /** Run the task body immediately, bypassing schedule/time-window guards. */
258
286
  async runNow(): Promise<void> {
@@ -497,19 +525,57 @@ export class ScheduledTask {
497
525
  };
498
526
  }
499
527
 
528
+ /**
529
+ * The zone this task's schedule is read in: its own, else `scheduler.timezone`.
530
+ *
531
+ * `undefined` when that resolves to the zone the process is already in, because
532
+ * `Bun.cron` evaluates a schedule in the system zone and its own tick is both
533
+ * cheaper and better tested than a minute tick with a match on top.
534
+ *
535
+ * @returns The zone to evaluate in, or `undefined` to let `Bun.cron` do it.
536
+ */
537
+ private _effectiveTimezone(): string | undefined {
538
+ const timezone = this._timezone ?? config.safe("scheduler.timezone", "");
539
+ if (!timezone) return undefined;
540
+ return timezone === Intl.DateTimeFormat().resolvedOptions().timeZone ? undefined : timezone;
541
+ }
542
+
500
543
  start(): void {
501
544
  if (this._handle) return;
502
545
  const handler = this._buildHandler();
503
- // Bun.cron supports croner's options form `(schedule, { run, timezone })` at
504
- // runtime, but @types/bun only types the `(schedule, handler)` overload — cast
505
- // when passing a timezone.
506
- const cronWithOptions = Bun.cron as unknown as (
507
- schedule: string,
508
- options: { run: () => void | Promise<void>; timezone: string },
509
- ) => { stop(): void };
510
- this._handle = this._timezone
511
- ? cronWithOptions(this._schedule, { run: handler, timezone: this._timezone })
512
- : Bun.cron(this._schedule, handler);
546
+ const timezone = this._effectiveTimezone();
547
+
548
+ if (!timezone) {
549
+ this._handle = Bun.cron(this._schedule, handler);
550
+ return;
551
+ }
552
+
553
+ if (!isValidTimeZone(timezone)) {
554
+ throw new UnknownTimeZoneError(this._name, timezone);
555
+ }
556
+
557
+ // `Bun.cron` takes `(schedule, handler)` and nothing else. The croner options
558
+ // form — `(schedule, { run, timezone })` — throws, and it throws during
559
+ // *registration*, so one task with a timezone took every other task in the app
560
+ // with it and the worker restart-looped. Setting a timezone is the obvious
561
+ // thing to do for a business that operates in one country and is not on UTC,
562
+ // which is most of them.
563
+ //
564
+ // So the schedule is evaluated here rather than by Bun. Bun ticks once a
565
+ // minute — the finest granularity it has, so nothing is given up — and the task
566
+ // runs on the ticks where the expression matches the wall clock in its own
567
+ // zone. Comparing against the zone's local time rather than an offset computed
568
+ // at registration is also what keeps it right across a DST change.
569
+ //
570
+ // A wall clock that skips an hour skips the schedules inside it, and one that
571
+ // repeats an hour runs them twice. That is what every cron does, and the
572
+ // alternative — inventing a firing time the clock never showed — is worse.
573
+ const expression = new CronExpression(this._schedule);
574
+
575
+ this._handle = Bun.cron(EVERY_MINUTE, async () => {
576
+ if (!expression.matchesIn(new Date(), timezone)) return;
577
+ await handler();
578
+ });
513
579
  }
514
580
 
515
581
  stop(): void {
@@ -24,10 +24,28 @@ export class SchedulerManager {
24
24
  return new SchedulerBuilder(this, name, callback);
25
25
  }
26
26
 
27
+ /**
28
+ * Register every task with the runtime's cron.
29
+ *
30
+ * A task that cannot be registered takes itself out and nothing else. Registration
31
+ * throws for exactly the reasons that are one task's problem — an expression the
32
+ * runtime rejects, a timezone it does not know — and letting one of them propagate
33
+ * meant the worker died during boot and restart-looped, so a typo in one schedule
34
+ * stopped every schedule in the app. Which ones those are is not a thing you can
35
+ * see from a crash loop.
36
+ */
27
37
  start(): void {
28
38
  this._started = true;
29
39
  for (const task of this._tasks.values()) {
30
- task.start();
40
+ try {
41
+ task.start();
42
+ } catch (err) {
43
+ frameworkLog("scheduler").error(
44
+ `Task "${task.name}" did not register and will not run: ${(err as Error).message ?? String(err)}`,
45
+ { task: task.name, schedule: task.schedule },
46
+ );
47
+ continue;
48
+ }
31
49
  frameworkLog("scheduler").info(`Started "${task.name}" — ${task.schedule}`);
32
50
  }
33
51
  }
package/src/config.ts CHANGED
@@ -2,8 +2,15 @@ import { deepMerge } from "@zerotal/core";
2
2
  import { DEFAULT_RUN_LOG_KEEP, DEFAULT_RUN_LOG_PATH } from "./runLog.ts";
3
3
 
4
4
  export interface SchedulerConfigShape {
5
- /** Timezone for cron expressions. Informational only - Bun.cron uses
6
- * the system timezone. Default: 'UTC' */
5
+ /**
6
+ * IANA timezone every schedule's cron expression is read in, unless the task
7
+ * sets its own with `.timezone(tz)`.
8
+ *
9
+ * Defaults to the system zone, so an app that does not set this keeps whatever
10
+ * its server does. Setting it is the usual case for a business that operates in
11
+ * one country and is not on UTC — one line instead of a `.timezone()` on every
12
+ * task.
13
+ */
7
14
  timezone: string;
8
15
  /** Durable run history — read with `schedule:runs` (see runLog.ts). */
9
16
  runLog: {
@@ -17,7 +24,10 @@ export interface SchedulerConfigShape {
17
24
  }
18
25
 
19
26
  const defaults: SchedulerConfigShape = {
20
- timezone: "UTC",
27
+ // The system zone rather than a hardcoded "UTC": the default has to be what the
28
+ // app already does, or turning this key from decoration into behaviour would have
29
+ // moved every schedule in every app that never set it.
30
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
21
31
  runLog: {
22
32
  path: DEFAULT_RUN_LOG_PATH,
23
33
  keep: DEFAULT_RUN_LOG_KEEP,
package/src/errors.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { ZerotalError } from "@zerotal/core";
2
+
3
+ /** Base class for all @zerotal/scheduler errors. */
4
+ export class SchedulerError extends ZerotalError {
5
+ constructor(
6
+ message: string,
7
+ code = "E_SCHEDULER",
8
+ status = 500,
9
+ context?: Record<string, unknown>,
10
+ ) {
11
+ super(message, code, status, context);
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Thrown when a task declares a timezone this runtime does not know.
17
+ *
18
+ * Raised at registration rather than at the first tick, because a `RangeError`
19
+ * thrown once a minute from inside a cron callback names the formatter and not the
20
+ * task — and a typo'd zone would otherwise be a task that silently never runs.
21
+ */
22
+ export class UnknownTimeZoneError extends SchedulerError {
23
+ constructor(task: string, timezone: string) {
24
+ super(
25
+ `[Zerotal Scheduler] Task "${task}" declares timezone "${timezone}", which this runtime ` +
26
+ `does not know. Use an IANA name, e.g. "Africa/Johannesburg" or "UTC".`,
27
+ "E_SCHEDULER_UNKNOWN_TIMEZONE",
28
+ 500,
29
+ { task, timezone },
30
+ );
31
+ }
32
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { SchedulerManager, SchedulerBuilder } from "./SchedulerManager.ts";
2
2
  export { ScheduledTask } from "./ScheduledTask.ts";
3
- export { CronExpression } from "./CronExpression.ts";
3
+ export { CronExpression, wallClockIn, isValidTimeZone } from "./CronExpression.ts";
4
+ export { SchedulerError, UnknownTimeZoneError } from "./errors.ts";
4
5
  export { SchedulerProvider } from "./provider/SchedulerProvider.ts";
5
6
 
6
7
  // Convention-based scheduling: extend Schedule, drop it in app/schedules/.