···11+MIT License
22+33+Copyright (c) 2026 Kasparas Bilkis
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+236
README.md
···11+# cron-fast
22+33+A fast and lightweight cron parser with timezone support and zero dependencies.
44+55+## Features
66+77+- **Lightweight** - Zero dependencies
88+- **Fast** - Optimal field increment algorithm
99+- **Tree-shakeable** - Import only what you need
1010+- **Timezone support** - Built-in timezone handling using native `Intl`
1111+- **Modern** - ESM + CJS, TypeScript-first
1212+- **Fully tested** - Comprehensive test coverage
1313+- **Simple API** - Clean, intuitive interface
1414+- **ISO 8601 compatible** - Works with all standard date formats
1515+1616+## Installation
1717+1818+```bash
1919+# npm
2020+npm install cron-fast
2121+2222+# pnpm
2323+pnpm add cron-fast
2424+2525+# yarn
2626+yarn add cron-fast
2727+2828+# jsr
2929+npx jsr add @kbilkis/cron-fast
3030+3131+# deno
3232+deno add jsr:@kbilkis/cron-fast
3333+```
3434+3535+## Quick Start
3636+3737+```typescript
3838+import { nextRun, previousRun, isValid } from "cron-fast";
3939+4040+// Get next execution time (UTC)
4141+const next = nextRun("0 9 * * *");
4242+console.log(next); // Next 9:00 AM UTC
4343+4444+// With timezone
4545+const nextNY = nextRun("0 9 * * *", { timezone: "America/New_York" });
4646+console.log(nextNY); // Next 9:00 AM Eastern Time
4747+4848+// Get previous execution
4949+const prev = previousRun("*/15 * * * *");
5050+5151+// Validate expression
5252+if (isValid("0 9 * * *")) {
5353+ console.log("Valid cron expression!");
5454+}
5555+```
5656+5757+## API
5858+5959+### `nextRun(expression, options?)`
6060+6161+Get the next execution time for a cron expression.
6262+6363+```typescript
6464+nextRun("0 9 * * *"); // Next 9:00 AM UTC
6565+nextRun("0 9 * * *", { timezone: "Europe/London" }); // Next 9:00 AM London time
6666+nextRun("0 9 * * *", { from: new Date("2026-03-15") }); // Next after Mar 15, 2026
6767+```
6868+6969+### `previousRun(expression, options?)`
7070+7171+Get the previous execution time.
7272+7373+```typescript
7474+previousRun("0 9 * * *"); // Last 9:00 AM UTC
7575+previousRun("0 9 * * *", { timezone: "Asia/Tokyo" });
7676+```
7777+7878+### `nextRuns(expression, count, options?)`
7979+8080+Get next N execution times.
8181+8282+```typescript
8383+nextRuns("0 9 * * *", 5); // Next 5 occurrences
8484+```
8585+8686+### `isValid(expression)`
8787+8888+Validate a cron expression.
8989+9090+```typescript
9191+isValid("0 9 * * *"); // true
9292+isValid("invalid"); // false
9393+```
9494+9595+### `isMatch(expression, date, options?)`
9696+9797+Check if a date matches the cron expression.
9898+9999+```typescript
100100+isMatch("0 9 * * *", new Date("2026-03-15T09:00:00Z")); // true
101101+```
102102+103103+### `parse(expression)`
104104+105105+Parse a cron expression into its components.
106106+107107+```typescript
108108+parse("0 9 * * 1-5");
109109+// Returns: { minute: [0], hour: [9], day: [1, 2, ..., 31], month: [0, 1, 2, ..., 11], weekday: [1,2,3,4,5] }
110110+```
111111+112112+## Cron Expression Format
113113+114114+```
115115+* * * * *
116116+│ │ │ │ │
117117+│ │ │ │ └─ Day of Week (0-7, SUN-SAT)
118118+│ │ │ └─── Month (1-12, JAN-DEC)
119119+│ │ └───── Day of Month (1-31)
120120+│ └─────── Hour (0-23)
121121+└───────── Minute (0-59)
122122+```
123123+124124+### Supported Special Characters
125125+126126+- `*` - Any value
127127+- `,` - Value list (e.g., `1,3,5`)
128128+- `-` - Range (e.g., `1-5`)
129129+- `/` - Step values (e.g., `*/5`)
130130+131131+## ISO 8601 Date Support
132132+133133+cron-fast fully supports ISO 8601 date formats for input:
134134+135135+```typescript
136136+// All these formats work:
137137+nextRun("0 9 * * *", { from: new Date("2026-03-15T14:30:00Z") }); // UTC
138138+nextRun("0 9 * * *", { from: new Date("2026-03-15T09:30:00-05:00") }); // With offset
139139+nextRun("0 9 * * *", { from: new Date("2026-03-15T14:30:00.500Z") }); // With milliseconds
140140+141141+// Different representations of the same moment produce identical results
142142+const utc = new Date("2026-03-15T14:30:00Z");
143143+const est = new Date("2026-03-15T09:30:00-05:00"); // Same moment
144144+nextRun("0 9 * * *", { from: utc }).getTime() === nextRun("0 9 * * *", { from: est }).getTime(); // true
145145+```
146146+147147+**Note:** All returned Date objects are in UTC (ending with `Z` in `.toISOString()`). Use `.toLocaleString()` to display in any timezone.
148148+149149+## Tree-Shaking
150150+151151+cron-fast is fully tree-shakeable. Import only what you need:
152152+153153+```typescript
154154+// Small bundle - only validation
155155+import { isValid } from "cron-fast";
156156+157157+// Medium bundle - one function + dependencies
158158+import { nextRun } from "cron-fast";
159159+160160+// Full bundle - everything
161161+import * as cron from "cron-fast";
162162+```
163163+164164+## Advanced Usage
165165+166166+### Working with Timezones
167167+168168+```typescript
169169+// Cron expression is interpreted in the specified timezone
170170+const next = nextRun("0 9 * * *", { timezone: "America/New_York" });
171171+172172+// The returned Date is always UTC internally
173173+console.log(next.toISOString()); // "2026-03-15T13:00:00.000Z" (9 AM EDT = 1 PM UTC)
174174+175175+// Display in any timezone
176176+console.log(next.toLocaleString("en-US", { timeZone: "America/New_York" }));
177177+// "3/15/2026, 9:00:00 AM"
178178+```
179179+180180+### Multiple Executions
181181+182182+```typescript
183183+// Get next 10 runs
184184+const runs = nextRuns("0 */6 * * *", 10); // Every 6 hours
185185+186186+// With timezone
187187+const runsNY = nextRuns("0 9 * * 1-5", 5, { timezone: "America/New_York" });
188188+// Next 5 weekday mornings in New York
189189+```
190190+191191+### Validation and Parsing
192192+193193+```typescript
194194+// Validate before using
195195+if (!isValid(userInput)) {
196196+ throw new Error("Invalid cron expression");
197197+}
198198+199199+// Parse to see what it means
200200+const parsed = parse("*/15 9-17 * * 1-5");
201201+console.log(parsed);
202202+// {
203203+// minute: [0, 15, 30, 45],
204204+// hour: [9, 10, 11, 12, 13, 14, 15, 16, 17],
205205+// day: [1-31],
206206+// month: [1-12],
207207+// weekday: [1, 2, 3, 4, 5]
208208+// }
209209+```
210210+211211+### Check if Date Matches
212212+213213+```typescript
214214+const now = new Date();
215215+216216+if (isMatch("0 9 * * 1-5", now)) {
217217+ console.log("It's 9 AM on a weekday!");
218218+}
219219+220220+// With timezone
221221+if (isMatch("0 9 * * *", now, { timezone: "America/New_York" })) {
222222+ console.log("It's 9 AM in New York!");
223223+}
224224+```
225225+226226+## Tips & Gotchas
227227+228228+- **Timezone handling**: The cron expression is interpreted in the timezone you specify, but the returned Date is always in UTC
229229+- **Daylight saving time**: Use IANA timezone names (like "America/New_York") instead of abbreviations (like "EST")
230230+- **Validation**: Always check `isValid()` before parsing user input
231231+- **Day 0 and 7**: Both represent Sunday in the day-of-week field
232232+- **Ranges are inclusive**: `1-5` includes both 1 and 5
233233+234234+## License
235235+236236+MIT
+58
examples/basic.ts
···11+import { nextRun, previousRun, nextRuns, isValid, parse } from "../src/index.js";
22+33+console.log("=== cron-fast Examples ===\n");
44+55+// Example 1: Basic usage
66+console.log('1. Next run for "0 9 * * *" (9 AM daily):');
77+const next = nextRun("0 9 * * *");
88+console.log(` ${next.toISOString()}\n`);
99+1010+// Example 2: With timezone
1111+console.log('2. Next run for "0 9 * * *" in New York timezone:');
1212+const nextNY = nextRun("0 9 * * *", { timezone: "America/New_York" });
1313+console.log(` ${nextNY.toISOString()}`);
1414+console.log(` (${nextNY.toLocaleString("en-US", { timeZone: "America/New_York" })})\n`);
1515+1616+// Example 3: Every 15 minutes
1717+console.log('3. Next 5 runs for "*/15 * * * *" (every 15 minutes):');
1818+const runs = nextRuns("*/15 * * * *", 5);
1919+runs.forEach((run, i) => {
2020+ console.log(` ${i + 1}. ${run.toISOString()}`);
2121+});
2222+console.log();
2323+2424+// Example 4: Weekdays only
2525+console.log('4. Next run for "0 9 * * 1-5" (9 AM weekdays):');
2626+const weekdayRun = nextRun("0 9 * * 1-5");
2727+console.log(` ${weekdayRun.toISOString()}`);
2828+console.log(
2929+ ` Day of week: ${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekdayRun.getUTCDay()]}\n`,
3030+);
3131+3232+// Example 5: Previous run
3333+console.log('5. Previous run for "0 */6 * * *" (every 6 hours):');
3434+const prev = previousRun("0 */6 * * *");
3535+console.log(` ${prev.toISOString()}\n`);
3636+3737+// Example 6: Validation
3838+console.log("6. Validate cron expressions:");
3939+console.log(` "0 9 * * *" is valid: ${isValid("0 9 * * *")}`);
4040+console.log(` "invalid" is valid: ${isValid("invalid")}\n`);
4141+4242+// Example 7: Parse expression
4343+console.log('7. Parse "0 9 * * 1-5":');
4444+const parsed = parse("0 9 * * 1-5");
4545+console.log(` Minutes: ${parsed.minute}`);
4646+console.log(` Hours: ${parsed.hour}`);
4747+console.log(` Weekdays: ${parsed.weekday} (Mon-Fri)\n`);
4848+4949+console.log("=== Performance Test ===\n");
5050+const iterations = 10000;
5151+const start = performance.now();
5252+for (let i = 0; i < iterations; i++) {
5353+ nextRun("*/15 * * * *");
5454+}
5555+const end = performance.now();
5656+const opsPerSec = Math.round((iterations / (end - start)) * 1000);
5757+console.log(`Calculated ${iterations} next runs in ${(end - start).toFixed(2)}ms`);
5858+console.log(`Performance: ${opsPerSec.toLocaleString()} ops/sec`);
···11+/**
22+ * cron-fast - Lightweight, fast cron parser with timezone support
33+ * @packageDocumentation
44+ */
55+66+export { nextRun, previousRun, nextRuns, isMatch } from "./scheduler.js";
77+export { parse, isValid } from "./parser.js";
88+export type { CronOptions, ParsedCron } from "./types.js";
+93
src/matcher.ts
···11+import type { ParsedCron } from "./types.js";
22+33+/**
44+ * Check if a date matches the cron expression
55+ */
66+export function matches(parsed: ParsedCron, date: Date): boolean {
77+ const minute = date.getUTCMinutes();
88+ const hour = date.getUTCHours();
99+ const day = date.getUTCDate();
1010+ const month = date.getUTCMonth(); // 0-indexed (0 = Jan, 11 = Dec)
1111+ const weekday = date.getUTCDay();
1212+1313+ // Check if all fields match
1414+ return (
1515+ parsed.minute.includes(minute) &&
1616+ parsed.hour.includes(hour) &&
1717+ parsed.month.includes(month) &&
1818+ matchesDayOrWeekday(parsed, day, weekday)
1919+ );
2020+}
2121+2222+/**
2323+ * Day-of-month and day-of-week use OR logic by default
2424+ * If both are restricted (not *), match either one
2525+ */
2626+function matchesDayOrWeekday(parsed: ParsedCron, day: number, weekday: number): boolean {
2727+ const dayMatches = parsed.day.includes(day);
2828+ const weekdayMatches = parsed.weekday.includes(weekday);
2929+3030+ // If both are wildcards (all values), both match
3131+ const dayIsWildcard = parsed.day.length === 31;
3232+ const weekdayIsWildcard = parsed.weekday.length === 7;
3333+3434+ // If both are restricted, use OR logic (standard cron behavior)
3535+ if (!dayIsWildcard && !weekdayIsWildcard) {
3636+ return dayMatches || weekdayMatches;
3737+ }
3838+3939+ // If only one is restricted, it must match
4040+ if (!dayIsWildcard) {
4141+ return dayMatches;
4242+ }
4343+ if (!weekdayIsWildcard) {
4444+ return weekdayMatches;
4545+ }
4646+4747+ // Both wildcards, always matches
4848+ return true;
4949+}
5050+5151+/**
5252+ * Find the next value in a sorted array that is >= target
5353+ * Returns null if no such value exists
5454+ *
5555+ * @param values - MUST be sorted in ascending order
5656+ * @param target - The minimum value to find
5757+ */
5858+export function findNext(values: number[], target: number): number | null {
5959+ for (const value of values) {
6060+ if (value >= target) {
6161+ return value;
6262+ }
6363+ }
6464+ return null;
6565+}
6666+6767+/**
6868+ * Find the previous value in a sorted array that is <= target
6969+ * Returns null if no such value exists
7070+ *
7171+ * @param values - MUST be sorted in ascending order
7272+ * @param target - The maximum value to find
7373+ */
7474+export function findPrevious(values: number[], target: number): number | null {
7575+ for (let i = values.length - 1; i >= 0; i--) {
7676+ if (values[i] <= target) {
7777+ return values[i];
7878+ }
7979+ }
8080+ return null;
8181+}
8282+8383+/**
8484+ * Get the number of days in a month
8585+ *
8686+ * @param year - The year
8787+ * @param month - The month (0-indexed: 0 = January, 11 = December)
8888+ * @returns The number of days in the month
8989+ */
9090+export function getDaysInMonth(year: number, month: number): number {
9191+ // Create date for first day of next month, then go back one day
9292+ return new Date(year, month + 1, 0).getDate();
9393+}
+225
src/parser.ts
···11+import type { ParsedCron } from "./types.js";
22+33+const MONTH_NAMES: Record<string, number> = {
44+ jan: 1,
55+ feb: 2,
66+ mar: 3,
77+ apr: 4,
88+ may: 5,
99+ jun: 6,
1010+ jul: 7,
1111+ aug: 8,
1212+ sep: 9,
1313+ oct: 10,
1414+ nov: 11,
1515+ dec: 12,
1616+};
1717+1818+const WEEKDAY_NAMES: Record<string, number> = {
1919+ sun: 0,
2020+ mon: 1,
2121+ tue: 2,
2222+ wed: 3,
2323+ thu: 4,
2424+ fri: 5,
2525+ sat: 6,
2626+};
2727+2828+/**
2929+ * Parse a cron expression into structured format
3030+ *
3131+ * Cron format: minute hour day month weekday
3232+ * - minute: 0-59
3333+ * - hour: 0-23
3434+ * - day: 1-31
3535+ * - month: 1-12 (or JAN-DEC)
3636+ * - weekday: 0-7 (or SUN-SAT, where 0 and 7 are Sunday)
3737+ *
3838+ * Note: Months are converted from cron's 1-indexed format (1-12) to
3939+ * JavaScript's 0-indexed format (0-11) for internal consistency.
4040+ */
4141+export function parse(expression: string): ParsedCron {
4242+ const trimmed = expression.trim();
4343+4444+ if (!trimmed) {
4545+ throw new Error("Cron expression cannot be empty");
4646+ }
4747+4848+ const parts = trimmed.split(/\s+/);
4949+5050+ if (parts.length !== 5) {
5151+ throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length}`);
5252+ }
5353+5454+ const [minuteStr, hourStr, dayStr, monthStr, weekdayStr] = parts;
5555+5656+ const weekdays = parseField(weekdayStr, 0, 7, WEEKDAY_NAMES).map((d) => (d === 7 ? 0 : d));
5757+5858+ const parsed: ParsedCron = {
5959+ minute: parseField(minuteStr, 0, 59),
6060+ hour: parseField(hourStr, 0, 23),
6161+ day: parseField(dayStr, 1, 31),
6262+ month: parseField(monthStr, 1, 12, MONTH_NAMES).map((m) => m - 1), // Convert to 0-indexed (0 = Jan, 11 = Dec)
6363+ weekday: Array.from(new Set(weekdays)).sort((a, b) => a - b), // Dedupe and sort
6464+ };
6565+6666+ // Validate day/month combinations
6767+ validateDayMonthCombinations(parsed);
6868+6969+ return parsed;
7070+}
7171+7272+/**
7373+ * Validate that day/month combinations are possible
7474+ * Rejects expressions like "0 0 31 2 *" (Feb 31) or "0 0 30 2 *" (Feb 30)
7575+ */
7676+function validateDayMonthCombinations(parsed: ParsedCron): void {
7777+ // If day or month is wildcard, no validation needed
7878+ const dayIsWildcard = parsed.day.length === 31;
7979+ const monthIsWildcard = parsed.month.length === 12;
8080+8181+ if (dayIsWildcard || monthIsWildcard) {
8282+ return;
8383+ }
8484+8585+ // Days in each month (0-indexed: 0=Jan, 11=Dec)
8686+ // February can have 29 days in leap years
8787+ const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
8888+8989+ // Check if any specified month can accommodate any specified day
9090+ let hasValidCombination = false;
9191+9292+ for (const month of parsed.month) {
9393+ const maxDaysInMonth = daysInMonth[month];
9494+9595+ for (const day of parsed.day) {
9696+ if (day <= maxDaysInMonth) {
9797+ hasValidCombination = true;
9898+ break;
9999+ }
100100+ }
101101+102102+ if (hasValidCombination) {
103103+ break;
104104+ }
105105+ }
106106+107107+ if (!hasValidCombination) {
108108+ throw new Error(`Invalid cron expression: no valid day/month combination exists`);
109109+ }
110110+}
111111+112112+/**
113113+ * Parse a single cron field (e.g., star-slash-5, 1-10, 1,3,5)
114114+ */
115115+function parseField(
116116+ field: string,
117117+ min: number,
118118+ max: number,
119119+ names?: Record<string, number>,
120120+): number[] {
121121+ const values = new Set<number>();
122122+123123+ // Handle wildcard
124124+ if (field === "*") {
125125+ for (let i = min; i <= max; i++) {
126126+ values.add(i);
127127+ }
128128+ return Array.from(values).sort((a, b) => a - b);
129129+ }
130130+131131+ // Split by comma for multiple values
132132+ const parts = field.split(",");
133133+134134+ for (const part of parts) {
135135+ // Handle step values (e.g., star-slash-5 or 10-20/2)
136136+ if (part.includes("/")) {
137137+ const [range, stepStr] = part.split("/");
138138+ const step = parseInt(stepStr, 10);
139139+140140+ if (isNaN(step) || step <= 0) {
141141+ throw new Error(`Invalid step value: ${stepStr}`);
142142+ }
143143+144144+ let start = min;
145145+ let end = max;
146146+147147+ if (range !== "*") {
148148+ if (range.includes("-")) {
149149+ const [startStr, endStr] = range.split("-");
150150+ start = parseValue(startStr, names);
151151+ end = parseValue(endStr, names);
152152+ } else {
153153+ start = parseValue(range, names);
154154+ }
155155+ }
156156+157157+ for (let i = start; i <= end; i += step) {
158158+ if (i >= min && i <= max) {
159159+ values.add(i);
160160+ }
161161+ }
162162+ }
163163+ // Handle ranges (e.g., 1-5)
164164+ else if (part.includes("-")) {
165165+ const [startStr, endStr] = part.split("-");
166166+ const start = parseValue(startStr, names);
167167+ const end = parseValue(endStr, names);
168168+169169+ if (start > end) {
170170+ throw new Error(`Invalid range: ${part}`);
171171+ }
172172+173173+ for (let i = start; i <= end; i++) {
174174+ if (i >= min && i <= max) {
175175+ values.add(i);
176176+ }
177177+ }
178178+ }
179179+ // Handle single values
180180+ else {
181181+ const value = parseValue(part, names);
182182+ if (value >= min && value <= max) {
183183+ values.add(value);
184184+ } else {
185185+ throw new Error(`Value ${value} out of range [${min}-${max}]`);
186186+ }
187187+ }
188188+ }
189189+190190+ if (values.size === 0) {
191191+ throw new Error(`No valid values in field: ${field}`);
192192+ }
193193+194194+ return Array.from(values).sort((a, b) => a - b);
195195+}
196196+197197+/**
198198+ * Parse a single value (number or name)
199199+ */
200200+function parseValue(value: string, names?: Record<string, number>): number {
201201+ const lower = value.toLowerCase();
202202+203203+ if (names && lower in names) {
204204+ return names[lower];
205205+ }
206206+207207+ const num = parseInt(value, 10);
208208+ if (isNaN(num)) {
209209+ throw new Error(`Invalid value: ${value}`);
210210+ }
211211+212212+ return num;
213213+}
214214+215215+/**
216216+ * Validate a cron expression
217217+ */
218218+export function isValid(expression: string): boolean {
219219+ try {
220220+ parse(expression);
221221+ return true;
222222+ } catch {
223223+ return false;
224224+ }
225225+}
+246
src/scheduler.ts
···11+import type { ParsedCron, CronOptions } from "./types.js";
22+import { parse } from "./parser.js";
33+import { matches, findNext, findPrevious, getDaysInMonth } from "./matcher.js";
44+import { convertToTimezone, convertFromTimezone } from "./timezone.js";
55+66+const MAX_ITERATIONS = 1000;
77+const ONE_MINUTE_MS = 60_000;
88+99+type Direction = "next" | "prev";
1010+1111+/** Direction-specific operations for unified forward/backward traversal */
1212+const DIR = {
1313+ next: {
1414+ find: findNext,
1515+ minute: (p: ParsedCron) => p.minute[0],
1616+ hour: (p: ParsedCron) => p.hour[0],
1717+ offset: 1,
1818+ },
1919+ prev: {
2020+ find: findPrevious,
2121+ minute: (p: ParsedCron) => p.minute.at(-1)!,
2222+ hour: (p: ParsedCron) => p.hour.at(-1)!,
2323+ offset: -1,
2424+ },
2525+} as const;
2626+2727+/** Get the next execution time for a cron expression */
2828+export function nextRun(expression: string, options?: CronOptions): Date {
2929+ const parsed = parse(expression);
3030+ const from = options?.from || new Date();
3131+ const tz = options?.timezone;
3232+3333+ const start = tz ? convertToTimezone(from, tz) : new Date(from);
3434+ start.setUTCSeconds(0, 0);
3535+ start.setUTCMinutes(start.getUTCMinutes() + 1);
3636+3737+ const result = findMatch(parsed, start, "next", tz);
3838+ if (!result) throw new Error("No matching time found within reasonable search window");
3939+ return result;
4040+}
4141+4242+/** Get the previous execution time for a cron expression */
4343+export function previousRun(expression: string, options?: CronOptions): Date {
4444+ const parsed = parse(expression);
4545+ const from = options?.from || new Date();
4646+ const tz = options?.timezone;
4747+4848+ const start = tz ? convertToTimezone(from, tz) : new Date(from);
4949+ start.setUTCSeconds(0, 0);
5050+ start.setUTCMinutes(start.getUTCMinutes() - 1);
5151+5252+ const result = findMatch(parsed, start, "prev", tz);
5353+ if (!result) throw new Error("No matching time found within reasonable search window");
5454+ return result;
5555+}
5656+5757+/** Get next N execution times */
5858+export function nextRuns(expression: string, count: number, options?: CronOptions): Date[] {
5959+ if (count <= 0) return [];
6060+6161+ const results: Date[] = [];
6262+ let current = options?.from || new Date();
6363+6464+ for (let i = 0; i < count; i++) {
6565+ const next = nextRun(expression, { ...options, from: current });
6666+ results.push(next);
6767+ current = new Date(next.getTime() + ONE_MINUTE_MS);
6868+ }
6969+ return results;
7070+}
7171+7272+/** Check if a date matches the cron expression */
7373+export function isMatch(
7474+ expression: string,
7575+ date: Date,
7676+ options?: Pick<CronOptions, "timezone">,
7777+): boolean {
7878+ const parsed = parse(expression);
7979+ const checkDate = options?.timezone ? convertToTimezone(date, options.timezone) : new Date(date);
8080+ return matches(parsed, checkDate);
8181+}
8282+8383+/** Find matching time using smart field-increment algorithm */
8484+function findMatch(parsed: ParsedCron, start: Date, dir: Direction, tz?: string): Date | null {
8585+ const current = new Date(start);
8686+8787+ for (let i = 0; i < MAX_ITERATIONS; i++) {
8888+ if (matches(parsed, current)) {
8989+ return tz ? convertFromTimezone(current, tz) : current;
9090+ }
9191+ advanceDate(parsed, current, dir);
9292+ }
9393+ return null;
9494+}
9595+9696+/**
9797+ * Advance date to next/prev candidate time by mutating the date in place.
9898+ *
9999+ * Algorithm:
100100+ * 1. Check fields from LARGEST (month) to SMALLEST (minute)
101101+ * 2. When a field doesn't match, jump to the next valid value for that field
102102+ * 3. Reset all smaller fields to their boundary (first value for 'next', last for 'prev')
103103+ *
104104+ * Example (direction='next', cron='0 9 * * *' meaning 9:00 AM daily):
105105+ * Current: March 15, 10:30 AM
106106+ * - Month (March)? ✓ matches
107107+ * - Day (15)? ✓ matches
108108+ * - Hour (10)? ✗ not in [9] → no next hour today → cascade to next day
109109+ * - Result: March 16, 9:00 AM
110110+ *
111111+ * @param parsed - The parsed cron expression
112112+ * @param date - The date to mutate (modified in place)
113113+ * @param dir - Direction to advance ('next' or 'prev')
114114+ */
115115+function advanceDate(parsed: ParsedCron, date: Date, dir: Direction): void {
116116+ const d = DIR[dir];
117117+ const minute = date.getUTCMinutes();
118118+ const hour = date.getUTCHours();
119119+ const day = date.getUTCDate();
120120+ const month = date.getUTCMonth();
121121+ const year = date.getUTCFullYear();
122122+ const daysInMonth = getDaysInMonth(year, month);
123123+124124+ // Month mismatch
125125+ if (!parsed.month.includes(month)) {
126126+ moveToMonth(parsed, date, dir, month, year);
127127+ return;
128128+ }
129129+130130+ // Day mismatch
131131+ if (!parsed.day.includes(day) || day > daysInMonth) {
132132+ moveToDay(parsed, date, dir, day, month, year, daysInMonth);
133133+ return;
134134+ }
135135+136136+ // Hour mismatch
137137+ if (!parsed.hour.includes(hour)) {
138138+ const targetHour = d.find(parsed.hour, hour + d.offset);
139139+ if (targetHour !== null) {
140140+ // Found valid hour in same day → reset minute to boundary
141141+ date.setUTCHours(targetHour);
142142+ date.setUTCMinutes(d.minute(parsed));
143143+ } else {
144144+ // No valid hour left today → move to next/prev day
145145+ moveToDay(parsed, date, dir, day, month, year, daysInMonth);
146146+ }
147147+ return;
148148+ }
149149+150150+ // Minute mismatch
151151+ if (!parsed.minute.includes(minute)) {
152152+ const targetMinute = d.find(parsed.minute, minute + d.offset);
153153+ if (targetMinute !== null) {
154154+ // Found valid minute in same hour
155155+ date.setUTCMinutes(targetMinute);
156156+ } else {
157157+ // No valid minute left → try next hour
158158+ const targetHour = d.find(parsed.hour, hour + d.offset);
159159+ if (targetHour !== null) {
160160+ date.setUTCHours(targetHour);
161161+ date.setUTCMinutes(d.minute(parsed));
162162+ } else {
163163+ // No valid hour left → move to next/prev day
164164+ moveToDay(parsed, date, dir, day, month, year, daysInMonth);
165165+ }
166166+ }
167167+ return;
168168+ }
169169+170170+ // Weekday mismatch: all fields match but wrong day-of-week.
171171+ // Skip directly to next/prev day since no hour/minute on this day can match.
172172+ moveToDay(parsed, date, dir, day, month, year, daysInMonth);
173173+}
174174+175175+function moveToMonth(
176176+ parsed: ParsedCron,
177177+ date: Date,
178178+ dir: Direction,
179179+ currentMonth: number,
180180+ currentYear: number,
181181+): void {
182182+ const d = DIR[dir];
183183+ const targetMonth = d.find(parsed.month, currentMonth + d.offset);
184184+185185+ if (targetMonth !== null) {
186186+ resetToMonthBoundary(parsed, date, currentYear, targetMonth, dir);
187187+ } else {
188188+ const boundaryMonth = dir === "next" ? parsed.month[0] : parsed.month.at(-1)!;
189189+ resetToMonthBoundary(parsed, date, currentYear + d.offset, boundaryMonth, dir);
190190+ }
191191+}
192192+193193+function moveToDay(
194194+ parsed: ParsedCron,
195195+ date: Date,
196196+ dir: Direction,
197197+ currentDay: number,
198198+ currentMonth: number,
199199+ currentYear: number,
200200+ daysInMonth: number,
201201+): void {
202202+ const d = DIR[dir];
203203+ const targetDay = d.find(parsed.day, currentDay + d.offset);
204204+ const dayIsValid =
205205+ dir === "next" ? targetDay !== null && targetDay <= daysInMonth : targetDay !== null;
206206+207207+ if (dayIsValid) {
208208+ date.setUTCDate(targetDay!);
209209+ date.setUTCHours(d.hour(parsed));
210210+ date.setUTCMinutes(d.minute(parsed));
211211+ } else {
212212+ moveToMonth(parsed, date, dir, currentMonth, currentYear);
213213+ }
214214+}
215215+216216+function resetToMonthBoundary(
217217+ parsed: ParsedCron,
218218+ date: Date,
219219+ year: number,
220220+ month: number,
221221+ dir: Direction,
222222+): void {
223223+ const d = DIR[dir];
224224+ date.setUTCFullYear(year);
225225+ date.setUTCDate(1);
226226+ date.setUTCMonth(month);
227227+228228+ const daysInMonth = getDaysInMonth(year, month);
229229+230230+ if (dir === "next") {
231231+ const validDay = findNext(parsed.day, 1);
232232+ date.setUTCDate(validDay !== null && validDay <= daysInMonth ? validDay : parsed.day[0]);
233233+ } else {
234234+ const prevDay = findPrevious(parsed.day, daysInMonth);
235235+ if (prevDay !== null) {
236236+ date.setUTCDate(prevDay);
237237+ } else {
238238+ // No valid day in this month, move to previous month
239239+ moveToMonth(parsed, date, dir, month, year);
240240+ return;
241241+ }
242242+ }
243243+244244+ date.setUTCHours(d.hour(parsed));
245245+ date.setUTCMinutes(d.minute(parsed));
246246+}
+135
src/timezone.ts
···11+/** Convert a UTC date to wall-clock time in the target timezone */
22+export function convertToTimezone(date: Date, timezone: string): Date {
33+ // Format the date in the target timezone
44+ const str = date.toLocaleString("en-US", {
55+ timeZone: timezone,
66+ year: "numeric",
77+ month: "2-digit",
88+ day: "2-digit",
99+ hour: "2-digit",
1010+ minute: "2-digit",
1111+ second: "2-digit",
1212+ hour12: false,
1313+ });
1414+1515+ // Parse formatted string: "MM/DD/YYYY, HH:mm:ss"
1616+ const [datePart, timePart] = str.split(", ");
1717+ const [month, day, year] = datePart.split("/").map(Number);
1818+ let [hour, minute, second] = timePart.split(":").map(Number);
1919+2020+ if (hour === 24) hour = 0; // Normalize "24:00:00" to "00:00:00"
2121+2222+ return new Date(Date.UTC(year, month - 1, day, hour, minute, second));
2323+}
2424+2525+/**
2626+ * Convert a timezone-local date back to UTC (inverse of convertToTimezone).
2727+ *
2828+ * Note: During DST fall-back, multiple UTC times map to the same wall-clock time.
2929+ * The result is implementation-defined. Avoid scheduling during DST transition hours
3030+ * for predictable behavior.
3131+ */
3232+export function convertFromTimezone(date: Date, timezone: string): Date {
3333+ const targetYear = date.getUTCFullYear();
3434+ const targetMonth = date.getUTCMonth();
3535+ const targetDay = date.getUTCDate();
3636+ const targetHour = date.getUTCHours();
3737+ const targetMinute = date.getUTCMinutes();
3838+ const targetSecond = date.getUTCSeconds();
3939+4040+ // Target time as a comparable number (for checking if we found it)
4141+ const targetTime = Date.UTC(
4242+ targetYear,
4343+ targetMonth,
4444+ targetDay,
4545+ targetHour,
4646+ targetMinute,
4747+ targetSecond,
4848+ );
4949+5050+ // Start with a guess: interpret the wall-clock time as UTC
5151+ let guess = targetTime;
5252+ let bestGuess = guess;
5353+ let bestDiff = Infinity;
5454+5555+ // Iteratively refine the guess (usually converges in 1-2 iterations)
5656+ for (let i = 0; i < 3; i++) {
5757+ const testDate = new Date(guess);
5858+ const testStr = testDate.toLocaleString("en-US", {
5959+ timeZone: timezone,
6060+ year: "numeric",
6161+ month: "2-digit",
6262+ day: "2-digit",
6363+ hour: "2-digit",
6464+ minute: "2-digit",
6565+ second: "2-digit",
6666+ hour12: false,
6767+ });
6868+6969+ // Parse what wall-clock time this guess produces
7070+ const [testDatePart, testTimePart] = testStr.split(", ");
7171+ const [testMonth, testDay, testYear] = testDatePart.split("/").map(Number);
7272+ let [testHour, testMinute, testSecond] = testTimePart.split(":").map(Number);
7373+7474+ if (testHour === 24) testHour = 0; // Normalize "24:00:00" to "00:00:00"
7575+7676+ const gotTime = Date.UTC(testYear, testMonth - 1, testDay, testHour, testMinute, testSecond);
7777+7878+ // Track the best guess (closest to target, but prefer later times if equal distance)
7979+ const diff = Math.abs(targetTime - gotTime);
8080+ if (diff < bestDiff || (diff === bestDiff && guess > bestGuess)) {
8181+ bestDiff = diff;
8282+ bestGuess = guess;
8383+ }
8484+8585+ // If we got what we wanted, we're done!
8686+ // Note: During DST fall-back, two UTC times map to the same wall-clock time.
8787+ // This returns whichever solution the iteration converges to first (implementation-defined).
8888+ if (gotTime === targetTime) {
8989+ return new Date(guess);
9090+ }
9191+9292+ // Otherwise, adjust the guess by the difference
9393+ const adjustment = targetTime - gotTime;
9494+ guess += adjustment;
9595+ }
9696+9797+ // If we didn't find an exact match after 3 iterations, we're likely in a DST gap
9898+ // (e.g., 2:30 AM during spring forward doesn't exist)
9999+ // Try one more time: check if adding 1 hour to the target gets us closer
100100+ const oneHourLater = targetTime + 60 * 60 * 1000;
101101+ let guessLater = oneHourLater;
102102+103103+ for (let i = 0; i < 2; i++) {
104104+ const testDate = new Date(guessLater);
105105+ const testStr = testDate.toLocaleString("en-US", {
106106+ timeZone: timezone,
107107+ year: "numeric",
108108+ month: "2-digit",
109109+ day: "2-digit",
110110+ hour: "2-digit",
111111+ minute: "2-digit",
112112+ second: "2-digit",
113113+ hour12: false,
114114+ });
115115+116116+ const [testDatePart, testTimePart] = testStr.split(", ");
117117+ const [testMonth, testDay, testYear] = testDatePart.split("/").map(Number);
118118+ let [testHour, testMinute, testSecond] = testTimePart.split(":").map(Number);
119119+120120+ if (testHour === 24) testHour = 0; // Normalize "24:00:00" to "00:00:00"
121121+122122+ const gotTime = Date.UTC(testYear, testMonth - 1, testDay, testHour, testMinute, testSecond);
123123+124124+ if (gotTime === oneHourLater) {
125125+ // Target time was in a DST gap, return the time after the gap
126126+ return new Date(guessLater);
127127+ }
128128+129129+ const adjustment = oneHourLater - gotTime;
130130+ guessLater += adjustment;
131131+ }
132132+133133+ // Return the best guess we found
134134+ return new Date(bestGuess);
135135+}
+24
src/types.ts
···11+/**
22+ * Options for cron execution time calculations
33+ */
44+export interface CronOptions {
55+ /** IANA timezone string (e.g., 'America/New_York', 'Europe/London') */
66+ timezone?: string;
77+ /** Reference date to calculate from (defaults to now) */
88+ from?: Date;
99+}
1010+1111+/**
1212+ * Parsed cron expression with valid values for each field
1313+ *
1414+ * Note: Internally, months are stored as 0-indexed (0 = January, 11 = December)
1515+ * to match JavaScript's Date object convention. The parser automatically converts
1616+ * from cron's 1-indexed format (1-12) to 0-indexed (0-11).
1717+ */
1818+export interface ParsedCron {
1919+ minute: number[]; // 0-59
2020+ hour: number[]; // 0-23
2121+ day: number[]; // 1-31
2222+ month: number[]; // 0-11 (0 = January, 11 = December)
2323+ weekday: number[]; // 0-6 (0 = Sunday, 6 = Saturday)
2424+}
+255
test/matcher.test.ts
···11+import { describe, it, expect } from "vitest";
22+import { matches, findNext, findPrevious, getDaysInMonth } from "../src/matcher.js";
33+import { parse } from "../src/parser.js";
44+55+describe("matcher", () => {
66+ describe("matches", () => {
77+ it("should match exact time", () => {
88+ const parsed = parse("30 14 * * *");
99+ const date = new Date("2026-03-15T14:30:00Z");
1010+1111+ expect(matches(parsed, date)).toBe(true);
1212+ });
1313+1414+ it("should not match different minute", () => {
1515+ const parsed = parse("30 14 * * *");
1616+ const date = new Date("2026-03-15T14:31:00Z");
1717+1818+ expect(matches(parsed, date)).toBe(false);
1919+ });
2020+2121+ it("should not match different hour", () => {
2222+ const parsed = parse("30 14 * * *");
2323+ const date = new Date("2026-03-15T15:30:00Z");
2424+2525+ expect(matches(parsed, date)).toBe(false);
2626+ });
2727+2828+ it("should match wildcard minute", () => {
2929+ const parsed = parse("* 14 * * *");
3030+ const date1 = new Date("2026-03-15T14:00:00Z");
3131+ const date2 = new Date("2026-03-15T14:30:00Z");
3232+ const date3 = new Date("2026-03-15T14:59:00Z");
3333+3434+ expect(matches(parsed, date1)).toBe(true);
3535+ expect(matches(parsed, date2)).toBe(true);
3636+ expect(matches(parsed, date3)).toBe(true);
3737+ });
3838+3939+ it("should match step values", () => {
4040+ const parsed = parse("*/15 * * * *");
4141+ const date1 = new Date("2026-03-15T14:00:00Z");
4242+ const date2 = new Date("2026-03-15T14:15:00Z");
4343+ const date3 = new Date("2026-03-15T14:30:00Z");
4444+ const date4 = new Date("2026-03-15T14:45:00Z");
4545+ const date5 = new Date("2026-03-15T14:10:00Z");
4646+4747+ expect(matches(parsed, date1)).toBe(true);
4848+ expect(matches(parsed, date2)).toBe(true);
4949+ expect(matches(parsed, date3)).toBe(true);
5050+ expect(matches(parsed, date4)).toBe(true);
5151+ expect(matches(parsed, date5)).toBe(false);
5252+ });
5353+5454+ it("should match specific day of month", () => {
5555+ const parsed = parse("0 9 15 * *");
5656+ const date1 = new Date("2026-03-15T09:00:00Z");
5757+ const date2 = new Date("2026-03-16T09:00:00Z");
5858+5959+ expect(matches(parsed, date1)).toBe(true);
6060+ expect(matches(parsed, date2)).toBe(false);
6161+ });
6262+6363+ it("should match specific month", () => {
6464+ const parsed = parse("0 9 * 3 *");
6565+ const date1 = new Date("2026-03-15T09:00:00Z");
6666+ const date2 = new Date("2026-04-15T09:00:00Z");
6767+6868+ expect(matches(parsed, date1)).toBe(true);
6969+ expect(matches(parsed, date2)).toBe(false);
7070+ });
7171+7272+ it("should match specific weekday", () => {
7373+ const parsed = parse("0 9 * * 1"); // Monday
7474+ const monday = new Date("2026-03-16T09:00:00Z"); // Monday
7575+ const tuesday = new Date("2026-03-17T09:00:00Z"); // Tuesday
7676+7777+ expect(matches(parsed, monday)).toBe(true);
7878+ expect(matches(parsed, tuesday)).toBe(false);
7979+ });
8080+8181+ it("should use OR logic when both day and weekday are specified", () => {
8282+ // Run on 15th OR on Mondays
8383+ const parsed = parse("0 9 15 * 1");
8484+ const june15Monday = new Date("2026-06-15T09:00:00Z"); // 15th AND Monday
8585+ const march16Monday = new Date("2026-03-16T09:00:00Z"); // 16th but Monday
8686+ const march15Sunday = new Date("2026-03-15T09:00:00Z"); // 15th but Sunday
8787+ const march17Tuesday = new Date("2026-03-17T09:00:00Z"); // 17th and Tuesday
8888+8989+ expect(matches(parsed, june15Monday)).toBe(true); // Matches both day=15 AND weekday=Monday
9090+ expect(matches(parsed, march16Monday)).toBe(true); // Matches weekday=Monday only
9191+ expect(matches(parsed, march15Sunday)).toBe(true); // Matches day=15 only
9292+ expect(matches(parsed, march17Tuesday)).toBe(false); // Matches neither
9393+ });
9494+9595+ it("should match range of values", () => {
9696+ const parsed = parse("0 9-17 * * *"); // 9 AM to 5 PM
9797+ const date1 = new Date("2026-03-15T09:00:00Z");
9898+ const date2 = new Date("2026-03-15T12:00:00Z");
9999+ const date3 = new Date("2026-03-15T17:00:00Z");
100100+ const date4 = new Date("2026-03-15T08:00:00Z");
101101+ const date5 = new Date("2026-03-15T18:00:00Z");
102102+103103+ expect(matches(parsed, date1)).toBe(true);
104104+ expect(matches(parsed, date2)).toBe(true);
105105+ expect(matches(parsed, date3)).toBe(true);
106106+ expect(matches(parsed, date4)).toBe(false);
107107+ expect(matches(parsed, date5)).toBe(false);
108108+ });
109109+110110+ it("should match list of values", () => {
111111+ const parsed = parse("0 9,12,15 * * *");
112112+ const date1 = new Date("2026-03-15T09:00:00Z");
113113+ const date2 = new Date("2026-03-15T12:00:00Z");
114114+ const date3 = new Date("2026-03-15T15:00:00Z");
115115+ const date4 = new Date("2026-03-15T10:00:00Z");
116116+117117+ expect(matches(parsed, date1)).toBe(true);
118118+ expect(matches(parsed, date2)).toBe(true);
119119+ expect(matches(parsed, date3)).toBe(true);
120120+ expect(matches(parsed, date4)).toBe(false);
121121+ });
122122+ });
123123+124124+ describe("findNext", () => {
125125+ it("should find next value equal to target", () => {
126126+ const values = [0, 15, 30, 45];
127127+ expect(findNext(values, 15)).toBe(15);
128128+ });
129129+130130+ it("should find next value greater than target", () => {
131131+ const values = [0, 15, 30, 45];
132132+ expect(findNext(values, 16)).toBe(30);
133133+ });
134134+135135+ it("should return first value if target is before all", () => {
136136+ const values = [0, 15, 30, 45];
137137+ expect(findNext(values, -1)).toBe(0);
138138+ });
139139+140140+ it("should return null if target is after all values", () => {
141141+ const values = [0, 15, 30, 45];
142142+ expect(findNext(values, 50)).toBeNull();
143143+ });
144144+145145+ it("should work with single value", () => {
146146+ const values = [30];
147147+ expect(findNext(values, 20)).toBe(30);
148148+ expect(findNext(values, 30)).toBe(30);
149149+ expect(findNext(values, 40)).toBeNull();
150150+ });
151151+152152+ it("should work with unsorted values", () => {
153153+ const values = [30, 0, 45, 15];
154154+ expect(findNext(values, 10)).toBe(30);
155155+ });
156156+ });
157157+158158+ describe("findPrevious", () => {
159159+ it("should find previous value equal to target", () => {
160160+ const values = [0, 15, 30, 45];
161161+ expect(findPrevious(values, 30)).toBe(30);
162162+ });
163163+164164+ it("should find previous value less than target", () => {
165165+ const values = [0, 15, 30, 45];
166166+ expect(findPrevious(values, 35)).toBe(30);
167167+ });
168168+169169+ it("should return last value if target is after all", () => {
170170+ const values = [0, 15, 30, 45];
171171+ expect(findPrevious(values, 50)).toBe(45);
172172+ });
173173+174174+ it("should return null if target is before all values", () => {
175175+ const values = [0, 15, 30, 45];
176176+ expect(findPrevious(values, -1)).toBeNull();
177177+ });
178178+179179+ it("should work with single value", () => {
180180+ const values = [30];
181181+ expect(findPrevious(values, 40)).toBe(30);
182182+ expect(findPrevious(values, 30)).toBe(30);
183183+ expect(findPrevious(values, 20)).toBeNull();
184184+ });
185185+186186+ it("should work with unsorted values", () => {
187187+ const values = [30, 0, 45, 15];
188188+ expect(findPrevious(values, 20)).toBe(15);
189189+ });
190190+ });
191191+192192+ describe("getDaysInMonth", () => {
193193+ it("should return 31 for January", () => {
194194+ expect(getDaysInMonth(2026, 0)).toBe(31);
195195+ });
196196+197197+ it("should return 28 for February in non-leap year", () => {
198198+ expect(getDaysInMonth(2026, 1)).toBe(28);
199199+ expect(getDaysInMonth(2027, 1)).toBe(28);
200200+ });
201201+202202+ it("should return 29 for February in leap year", () => {
203203+ expect(getDaysInMonth(2028, 1)).toBe(29);
204204+ expect(getDaysInMonth(2024, 1)).toBe(29);
205205+ });
206206+207207+ it("should return 31 for March", () => {
208208+ expect(getDaysInMonth(2026, 2)).toBe(31);
209209+ });
210210+211211+ it("should return 30 for April", () => {
212212+ expect(getDaysInMonth(2026, 3)).toBe(30);
213213+ });
214214+215215+ it("should return 31 for May", () => {
216216+ expect(getDaysInMonth(2026, 4)).toBe(31);
217217+ });
218218+219219+ it("should return 30 for June", () => {
220220+ expect(getDaysInMonth(2026, 5)).toBe(30);
221221+ });
222222+223223+ it("should return 31 for July", () => {
224224+ expect(getDaysInMonth(2026, 6)).toBe(31);
225225+ });
226226+227227+ it("should return 31 for August", () => {
228228+ expect(getDaysInMonth(2026, 7)).toBe(31);
229229+ });
230230+231231+ it("should return 30 for September", () => {
232232+ expect(getDaysInMonth(2026, 8)).toBe(30);
233233+ });
234234+235235+ it("should return 31 for October", () => {
236236+ expect(getDaysInMonth(2026, 9)).toBe(31);
237237+ });
238238+239239+ it("should return 30 for November", () => {
240240+ expect(getDaysInMonth(2026, 10)).toBe(30);
241241+ });
242242+243243+ it("should return 31 for December", () => {
244244+ expect(getDaysInMonth(2026, 11)).toBe(31);
245245+ });
246246+247247+ it("should handle leap year 2000 (divisible by 400)", () => {
248248+ expect(getDaysInMonth(2000, 1)).toBe(29);
249249+ });
250250+251251+ it("should handle non-leap year 1900 (divisible by 100 but not 400)", () => {
252252+ expect(getDaysInMonth(1900, 1)).toBe(28);
253253+ });
254254+ });
255255+});