Repository: kelektiv/node-cron
Pull request #966: fix: cron should still execute after changing the time back during daylight savings
Author: login: intcreator
Labels: name: released
Created: 2025-02-24T19:11:52Z | Merged: 2025-03-26T19:24:28Z

<!--- Provide a general summary of your changes in the Title above (following the Conventional Commits standard) -->
<!-- More infos: https://www.conventionalcommits.org -->
<!-- Commit types: https://github.com/insurgent-lab/conventional-changelog-preset#commit-types-->

## Description

<!--- Describe your changes in detail -->
This PR will fix the issue where cron did not execute after changing the time back during daylight savings. It also replaces `ts-jest` with `@swc/jest` which is a little faster at running tests.

## Related Issue

<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps to reproduce -->
<!--- Please link to the issue here: -->
fixes #881

## Motivation and Context

<!--- Why is this change required? What problem does it solve? -->
I ended up doing a big refactor to fix this issue because everything to do with time zones seemed too complicated to understand as is. I decided that rather than trying to decide based on what the time "should" be after a change compared to what it actually was from Luxon, we should just check if the UTC offset changed. That would indicate a daylight savings jump with very little work on our part. Then we could just check if a cron execution was supposed to happen in the gap we skipped.

Building on that idea, I decided to ultimately create a new fake cron time in UTC based on the timezoned time, do all the manipulations without worrying about time zones, and then convert back into the timezoned time and figure out any adjustments if needed.

As for #881 specifically, it looks like some of our date creations were ambiguous. According to the Luxon docs, [you can sometimes create ambiguous times](https://moment.github.io/luxon/#/zones?id=dst-weirdness) if you try to initialize a `DateTime` object in the middle of a daylight savings jumping back event. if the time jumps back and hour at 3 am and I create a `DateTime` at 2:30 am on that day, is it the first 2:30 am or the second 2:30 am? I switched some of our `DateTime` creations to UTC to make the time they represented unambiguous, and I added some checks to see if the previous hour was ambiguous or not before returning the time.

## How Has This Been Tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
Added a new test case, made sure previous test cases passed (and edited some to make them more clear as to the times being tested), and removed some test cases that tested functions I removed that we no longer needed.

## Screenshots (if appropriate):

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [ ] If my change introduces a breaking change, I have added a `!` after the type/scope in the title (see the Conventional Commits standard).


Linked issue #881: Missing Cron Execution during DST change
### Description

Hi, thanks to author and everyone contributing for making this amazing library possible.

Encountered an issue where the cron jobs aren't triggering during daylight saving change.

For example: TimeZone - Australia/Melbourne 7 Apr 2024 - Daylight savings time ended and clocks turned back 1 hour
Time goes from 2:59 -> 2:00 (See example below)

### Expected Behavior

The Cron job should trigger every half an hour (See example below)

### Actual Behavior

The Cron job didn't trigger for one hour and missed two executions. Missing `cron job executed` log two times (See example below)


### Steps to Reproduce

```
const { CronJob } = require("cron");
const fakeTImers = require("@sinonjs/fake-timers");

process.env.TZ = "Australia/Melbourne";

const main = () => {
  const clock = fakeTImers.install({
    now: new Date("2024-04-07T01:00:00.000"),
  });
  const job = new CronJob(
    "*/30 * * * *", // every 30 minutes
    function () {
      console.log(
        "Cron job executed. The current time is: ",
        new Date().toString()
      );
    },
    null,
    true,
    "Australia/Melbourne"
  );

  console.log("current time is: ", new Date().toString());
  console.log("Going to advance time by 30 minutes");
  clock.tick(1000 * 60 * 30);
  console.log("Time after advancing is ", new Date().toString());
  console.log("\n");

  console.log("current time is: ", new Date().toString());
  console.log("Going to advance time by 30 minutes");
  clock.tick(1000 * 60 * 30);
  console.log("Time after advancing is ", new Date().toString());
  console.log("\n");

  console.log("current time is: ", new Date().toString());
  console.log("Going to advance time by 30 minutes");
  clock.tick(1000 * 60 * 30);
  console.log("Time after advancing is ", new Date().toString());
  console.log("\n");

  console.log("current time is: ", new Date().toString());
  console.log("Going to advance time by 30 minutes");
  clock.tick(1000 * 60 * 30);
  console.log("Time after advancing is ", new Date().toString());
  console.log("\n");

  console.log("current time is: ", new Date().toString());
  console.log("Going to advance time by 30 minutes");
  clock.tick(1000 * 60 * 30);
  console.log("Time after advancing is ", new Date().toString());
  console.log("\n");

  console.log("current time is: ", new Date().toString());
  console.log("Going to advance time by 30 minutes");
  clock.tick(1000 * 60 * 30);
  console.log("Time after advancing is ", new Date().toString());
  console.log("\n");

  console.log("current time is: ", new Date().toString());
  console.log("Going to advance time by 30 minutes");
  clock.tick(1000 * 60 * 30);
  console.log("Time after advancing is ", new Date().toString());
  console.log("\n");
};

main();
```




Output:

```
current time is:  Sun Apr 07 2024 01:00:00 GMT+1100 (Australian Eastern Daylight Time)
Going to advance time by 30 minutes
Cron job executed. The current time is:  Sun Apr 07 2024 01:30:00 GMT+1100 (Australian Eastern Daylight Time)
Time after advancing is  Sun Apr 07 2024 01:30:00 GMT+1100 (Australian Eastern Daylight Time)


current time is:  Sun Apr 07 2024 01:30:00 GMT+1100 (Australian Eastern Daylight Time)
Going to advance time by 30 minutes
Cron job executed. The current time is:  Sun Apr 07 2024 02:00:00 GMT+1100 (Australian Eastern Daylight Time)
Time after advancing is  Sun Apr 07 2024 02:00:00 GMT+1100 (Australian Eastern Daylight Time)


current time is:  Sun Apr 07 2024 02:00:00 GMT+1100 (Australian Eastern Daylight Time)
Going to advance time by 30 minutes
Cron job executed. The current time is:  Sun Apr 07 2024 02:30:00 GMT+1100 (Australian Eastern Daylight Time)
Time after advancing is  Sun Apr 07 2024 02:30:00 GMT+1100 (Australian Eastern Daylight Time)


current time is:  Sun Apr 07 2024 02:30:00 GMT+1100 (Australian Eastern Daylight Time)
Going to advance time by 30 minutes
Time after advancing is  Sun Apr 07 2024 02:00:00 GMT+1000 (Australian Eastern Standard Time)


current time is:  Sun Apr 07 2024 02:00:00 GMT+1000 (Australian Eastern Standard Time)
Going to advance time by 30 minutes
Time after advancing is  Sun Apr 07 2024 02:30:00 GMT+1000 (Australian Eastern Standard Time)


current time is:  Sun Apr 07 2024 02:30:00 GMT+1000 (Australian Eastern Standard Time)
Going to advance time by 30 minutes
Cron job executed. The current time is:  Sun Apr 07 2024 03:00:00 GMT+1000 (Australian Eastern Standard Time)
Time after advancing is  Sun Apr 07 2024 03:00:00 GMT+1000 (Australian Eastern Standard Time)


current time is:  Sun Apr 07 2024 03:00:00 GMT+1000 (Australian Eastern Standard Time)
Going to advance time by 30 minutes
Cron job executed. The current time is:  Sun Apr 07 2024 03:30:00 GMT+1000 (Australian Eastern Standard Time)
Time after advancing is  Sun Apr 07 2024 03:30:00 GMT+1000 (Australian Eastern Standard Time)


```



### Your Environment

- **`cron` version**: 3.1.7
- **NodeJS version**: 14.17.6
- **Operating System and version**: Ubuntu 16.04
- **TypeScript version (if applicable)**:
- **Link to your project (if applicable)**:



Might be related to: #563  #638 
Linked issue comments: user: login: sheerlox
created_at: 2024-05-17T08:33:40Z
body: Hi, thanks for reporting and providing a reproduction means.

I took note of the issue and will try to dedicate some time to it when possible.
author_association: COLLABORATOR
user: login: edwhittle
created_at: 2024-12-10T23:50:47Z
body: Hi @sheerlox - Is there a chance this could be picked up in the new year? The issue will re-occur on the 6th April 2025
author_association: NONE
user: login: intcreator
created_at: 2024-12-11T19:39:59Z
body: I'll look into it more. currently this repo is maintained by mostly me and Sheerlox though, all in our spare time, so we welcome outside help to address this issue! please consider opening a PR if you think you have a fix
author_association: COLLABORATOR
user: login: sheerlox
created_at: 2024-12-29T22:24:10Z
body: I was hoping this would be fixed by #938, but unfortunately no. Posting the test case here for later:

```ts
// https://github.com/kelektiv/node-cron/issues/881
it('should not skip executions in Melbourne DST backward jump', () => {
	const d = DateTime.fromISO('2024-04-07T02:59:00', {
		zone: 'Australia/Melbourne'
	}).toJSDate();
	const clock = sinon.useFakeTimers(d.getTime());

	console.debug({ d });

	const job = new CronJob(
		'*/30 * 7 4 *',
		callback,
		null,
		true,
		'Australia/Melbourne'
	);

	const oneHour = 60 * 60 * 1000;
	clock.tick(oneHour);
	expect(callback).toHaveBeenCalledTimes(2);

	clock.restore();
	job.stop();
});

```
author_association: COLLABORATOR
user: login: sheerlox
created_at: 2025-02-18T12:23:27Z
body: 47 days left ... 😅 @intcreator any chance you can take a look at this one before then?
author_association: COLLABORATOR
user: login: intcreator
created_at: 2025-02-18T18:53:01Z
body: yeah that's my next project once I get the maintenance PRs working again
author_association: COLLABORATOR
user: login: ncb000gt
created_at: 2025-03-26T19:29:58Z
body: :tada: This issue has been resolved in version 4.1.1 :tada:

The release is available on:
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/cron/v/4.1.1)
- [GitHub release](https://github.com/kelektiv/node-cron/releases/tag/v4.1.1)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: MEMBER

Diff:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 49fe3b75..9af73b74 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -76,7 +76,8 @@ export default [
 				}
 			],
 			// this is set to warn because it wasn't caught before the eslint migration in 11/2024
-			'@typescript-eslint/restrict-template-expressions': 'warn'
+			'@typescript-eslint/restrict-template-expressions': 'warn',
+			'capitalized-comments': ['error', 'never']
 		}
 	},
 
diff --git a/examples/at_10_minutes.js b/examples/at_10_minutes.mjs
similarity index 82%
rename from examples/at_10_minutes.js
rename to examples/at_10_minutes.mjs
index dfcf376c..3f131a65 100644
--- a/examples/at_10_minutes.js
+++ b/examples/at_10_minutes.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('* 10 * * * *', function () {
diff --git a/examples/at_midnight.js b/examples/at_midnight.mjs
similarity index 82%
rename from examples/at_midnight.js
rename to examples/at_midnight.mjs
index 204475fa..14e015ac 100644
--- a/examples/at_midnight.js
+++ b/examples/at_midnight.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('00 00 00 * * *', function () {
diff --git a/examples/basic.js b/examples/basic.mjs
similarity index 82%
rename from examples/basic.js
rename to examples/basic.mjs
index 94b5229d..618aeff6 100644
--- a/examples/basic.js
+++ b/examples/basic.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('* * * * * *', function () {
diff --git a/examples/complex_expr.js b/examples/complex_expr.mjs
similarity index 85%
rename from examples/complex_expr.js
rename to examples/complex_expr.mjs
index 9188ca0a..dfccb965 100644
--- a/examples/complex_expr.js
+++ b/examples/complex_expr.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('* 4-22 * * 1-5', function () {
diff --git a/examples/every_10_minutes.js b/examples/every_10_minutes.mjs
similarity index 83%
rename from examples/every_10_minutes.js
rename to examples/every_10_minutes.mjs
index 511bce2a..222f7eb6 100644
--- a/examples/every_10_minutes.js
+++ b/examples/every_10_minutes.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('0 */10 * * * *', function () {
diff --git a/examples/every_30_minutes_between_9_and_5.js b/examples/every_30_minutes_between_9_and_5.mjs
similarity index 84%
rename from examples/every_30_minutes_between_9_and_5.js
rename to examples/every_30_minutes_between_9_and_5.mjs
index 73a269dd..af7b9296 100644
--- a/examples/every_30_minutes_between_9_and_5.js
+++ b/examples/every_30_minutes_between_9_and_5.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('0 */30 9-17 * * *', function () {
diff --git a/examples/get_next_runs.js b/examples/get_next_runs.mjs
similarity index 88%
rename from examples/get_next_runs.js
rename to examples/get_next_runs.mjs
index eab53870..1b284a08 100644
--- a/examples/get_next_runs.js
+++ b/examples/get_next_runs.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 const job = new CronJob(
 	'0 * * * * *',
diff --git a/examples/in_the_past.js b/examples/in_the_past.mjs
similarity index 89%
rename from examples/in_the_past.js
rename to examples/in_the_past.mjs
index 1685ec74..417748e4 100644
--- a/examples/in_the_past.js
+++ b/examples/in_the_past.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 // XXX: SEE README GOTCHAS ABOUT WHY THIS COULD BE IN THE PAST!
 const d = new Date();
diff --git a/examples/is_crontime_valid.js b/examples/is_crontime_valid.mjs
similarity index 54%
rename from examples/is_crontime_valid.js
rename to examples/is_crontime_valid.mjs
index 443733da..67135552 100644
--- a/examples/is_crontime_valid.js
+++ b/examples/is_crontime_valid.mjs
@@ -1,3 +1,3 @@
-import { validateCronExpression } from '../src';
+import { validateCronExpression } from '../dist/index.js';
 
 console.log('is valid? ', validateCronExpression('NOT VALID').valid);
diff --git a/examples/is_job_running.js b/examples/is_job_running.mjs
similarity index 85%
rename from examples/is_job_running.js
rename to examples/is_job_running.mjs
index 9e53a5b3..297eb036 100644
--- a/examples/is_job_running.js
+++ b/examples/is_job_running.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('* * * * * *', function () {
diff --git a/examples/long_running_on_tick.js b/examples/long_running_on_tick.mjs
similarity index 91%
rename from examples/long_running_on_tick.js
rename to examples/long_running_on_tick.mjs
index bba12727..03381d40 100644
--- a/examples/long_running_on_tick.js
+++ b/examples/long_running_on_tick.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 let isRunning = false;
 console.log('Before job instantiation');
diff --git a/examples/mon_to_fri_at_11_30.js b/examples/mon_to_fri_at_11_30.mjs
similarity index 82%
rename from examples/mon_to_fri_at_11_30.js
rename to examples/mon_to_fri_at_11_30.mjs
index 296e697d..7ef33ec7 100644
--- a/examples/mon_to_fri_at_11_30.js
+++ b/examples/mon_to_fri_at_11_30.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('00 30 11 * * 1-5', function () {
diff --git a/examples/multiple_jobs.js b/examples/multiple_jobs.mjs
similarity index 88%
rename from examples/multiple_jobs.js
rename to examples/multiple_jobs.mjs
index 7fea9d14..bf3f06ff 100644
--- a/examples/multiple_jobs.js
+++ b/examples/multiple_jobs.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const job = new CronJob('*/5 * * * * *', function () {
diff --git a/examples/object_param.js b/examples/object_param.mjs
similarity index 92%
rename from examples/object_param.js
rename to examples/object_param.mjs
index a62a752d..37b3ae2b 100644
--- a/examples/object_param.js
+++ b/examples/object_param.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 let isRunning = false;
 console.log('Before job instantiation');
diff --git a/examples/run_at_specific_date.js b/examples/run_at_specific_date.mjs
similarity index 86%
rename from examples/run_at_specific_date.js
rename to examples/run_at_specific_date.mjs
index 2d8e21c4..00dfaa73 100644
--- a/examples/run_at_specific_date.js
+++ b/examples/run_at_specific_date.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('Before job instantiation');
 const date = new Date();
diff --git a/examples/time_dom_syntax_with_tz.js b/examples/time_dom_syntax_with_tz.mjs
similarity index 79%
rename from examples/time_dom_syntax_with_tz.js
rename to examples/time_dom_syntax_with_tz.mjs
index e383981c..1f221de9 100644
--- a/examples/time_dom_syntax_with_tz.js
+++ b/examples/time_dom_syntax_with_tz.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('first');
 const job = new CronJob(
diff --git a/examples/utc_offset_syntax.js b/examples/utc_offset_syntax.mjs
similarity index 82%
rename from examples/utc_offset_syntax.js
rename to examples/utc_offset_syntax.mjs
index e326b7a9..710d2e00 100644
--- a/examples/utc_offset_syntax.js
+++ b/examples/utc_offset_syntax.mjs
@@ -1,4 +1,4 @@
-import { CronJob } from '../src';
+import { CronJob } from '../dist/index.js';
 
 console.log('first');
 const job = new CronJob(
diff --git a/jest.config.json b/jest.config.json
index 94db4699..31e4a37b 100644
--- a/jest.config.json
+++ b/jest.config.json
@@ -1,6 +1,8 @@
 {
-	"preset": "ts-jest",
 	"testEnvironment": "node",
+	"transform": {
+		"^.+\\.(t|j)sx?$": "@swc/jest"
+	},
 	"collectCoverage": true,
 	"coverageThreshold": {
 		"global": {
diff --git a/package-lock.json b/package-lock.json
index df18dcaa..a3113e53 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,8 @@
 				"@semantic-release/github": "11.0.1",
 				"@semantic-release/npm": "12.0.1",
 				"@semantic-release/release-notes-generator": "14.0.3",
+				"@swc/core": "^1.10.18",
+				"@swc/jest": "^0.2.37",
 				"@types/jest": "29.5.14",
 				"@types/node": "22.13.11",
 				"@types/sinon": "17.0.4",
@@ -38,7 +40,6 @@
 				"prettier": "3.5.3",
 				"semantic-release": "24.2.3",
 				"sinon": "19.0.4",
-				"ts-jest": "29.2.6",
 				"typescript": "5.8.2",
 				"typescript-eslint": "7.18.0"
 			},
@@ -1326,6 +1327,19 @@
 				"node": ">=8"
 			}
 		},
+		"node_modules/@jest/create-cache-key-function": {
+			"version": "29.7.0",
+			"resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz",
+			"integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@jest/types": "^29.6.3"
+			},
+			"engines": {
+				"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+			}
+		},
 		"node_modules/@jest/environment": {
 			"version": "29.7.0",
 			"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
@@ -2579,6 +2593,250 @@
 			"dev": true,
 			"license": "(Unlicense OR Apache-2.0)"
 		},
+		"node_modules/@swc/core": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.7.tgz",
+			"integrity": "sha512-ICuzjyfz8Hh3U16Mb21uCRJeJd/lUgV999GjgvPhJSISM1L8GDSB5/AMNcwuGs7gFywTKI4vAeeXWyCETUXHAg==",
+			"dev": true,
+			"hasInstallScript": true,
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@swc/counter": "^0.1.3",
+				"@swc/types": "^0.1.19"
+			},
+			"engines": {
+				"node": ">=10"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/swc"
+			},
+			"optionalDependencies": {
+				"@swc/core-darwin-arm64": "1.11.7",
+				"@swc/core-darwin-x64": "1.11.7",
+				"@swc/core-linux-arm-gnueabihf": "1.11.7",
+				"@swc/core-linux-arm64-gnu": "1.11.7",
+				"@swc/core-linux-arm64-musl": "1.11.7",
+				"@swc/core-linux-x64-gnu": "1.11.7",
+				"@swc/core-linux-x64-musl": "1.11.7",
+				"@swc/core-win32-arm64-msvc": "1.11.7",
+				"@swc/core-win32-ia32-msvc": "1.11.7",
+				"@swc/core-win32-x64-msvc": "1.11.7"
+			},
+			"peerDependencies": {
+				"@swc/helpers": "*"
+			},
+			"peerDependenciesMeta": {
+				"@swc/helpers": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@swc/core-darwin-arm64": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.7.tgz",
+			"integrity": "sha512-3+LhCP2H50CLI6yv/lhOtoZ5B/hi7Q/23dye1KhbSDeDprLTm/KfLJh/iQqwaHUponf5m8C2U0y6DD+HGLz8Yw==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-darwin-x64": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.7.tgz",
+			"integrity": "sha512-1diWpJqwX1XmOghf9ENFaeRaTtqLiqlZIW56RfOqmeZ7tPp3qS7VygWb9akptBsO5pEA5ZwNgSerD6AJlQcjAw==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-linux-arm-gnueabihf": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.7.tgz",
+			"integrity": "sha512-MV8+hLREf0NN23NuSKemsjFaWjl/HnqdOkE7uhXTnHzg8WTwp6ddVtU5Yriv15+d/ktfLWPVAOhLHQ4gzaoa8A==",
+			"cpu": [
+				"arm"
+			],
+			"dev": true,
+			"license": "Apache-2.0",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-linux-arm64-gnu": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.7.tgz",
+			"integrity": "sha512-5GNs8ZjHQy/UTSnzzn+gm1RCUpCYo43lsxYOl8mpcnZSfxkNFVpjfylBv0QuJ5qhdfZ2iU55+v4iJCwCMtw0nA==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-linux-arm64-musl": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.7.tgz",
+			"integrity": "sha512-cTydaYBwDbVV5CspwVcCp9IevYWpGD1cF5B5KlBdjmBzxxeWyTAJRtKzn8w5/UJe/MfdAptarpqMPIs2f33YEQ==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-linux-x64-gnu": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.7.tgz",
+			"integrity": "sha512-YAX2KfYPlbDsnZiVMI4ZwotF3VeURUrzD+emJgFf1g26F4eEmslldgnDrKybW7V+bObsH22cDqoy6jmQZgpuPQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-linux-x64-musl": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.7.tgz",
+			"integrity": "sha512-mYT6FTDZyYx5pailc8xt6ClS2yjKmP8jNHxA9Ce3K21n5qkKilI5M2N7NShwXkd3Ksw3F29wKrg+wvEMXTRY/A==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-win32-arm64-msvc": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.7.tgz",
+			"integrity": "sha512-uLDQEcv0BHcepypstyxKkNsW6KfLyI5jVxTbcxka+B2UnMcFpvoR87nGt2JYW0grO2SNZPoFz+UnoKL9c6JxpA==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-win32-ia32-msvc": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.7.tgz",
+			"integrity": "sha512-wiq5G3fRizdxAJVFcon7zpyfbfrb+YShuTy+TqJ4Nf5PC0ueMOXmsmeuyQGApn6dVWtGCyymYQYt77wHeQajdA==",
+			"cpu": [
+				"ia32"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/core-win32-x64-msvc": {
+			"version": "1.11.7",
+			"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.7.tgz",
+			"integrity": "sha512-/zQdqY4fHkSORxEJ2cKtRBOwglvf/8gs6Tl4Q6VMx2zFtFpIOwFQstfY5u8wBNN2Z+PkAzyUCPoi8/cQFK8HLQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "Apache-2.0 AND MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/@swc/counter": {
+			"version": "0.1.3",
+			"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+			"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+			"dev": true,
+			"license": "Apache-2.0"
+		},
+		"node_modules/@swc/jest": {
+			"version": "0.2.37",
+			"resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.37.tgz",
+			"integrity": "sha512-CR2BHhmXKGxTiFr21DYPRHQunLkX3mNIFGFkxBGji6r9uyIR5zftTOVYj1e0sFNMV2H7mf/+vpaglqaryBtqfQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@jest/create-cache-key-function": "^29.7.0",
+				"@swc/counter": "^0.1.3",
+				"jsonc-parser": "^3.2.0"
+			},
+			"engines": {
+				"npm": ">= 7.0.0"
+			},
+			"peerDependencies": {
+				"@swc/core": "*"
+			}
+		},
+		"node_modules/@swc/types": {
+			"version": "0.1.19",
+			"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz",
+			"integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@swc/counter": "^0.1.3"
+			}
+		},
 		"node_modules/@types/babel__core": {
 			"version": "7.20.5",
 			"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3653,13 +3911,6 @@
 				"node": ">=12"
 			}
 		},
-		"node_modules/async": {
-			"version": "3.2.6",
-			"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
-			"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
-			"dev": true,
-			"license": "MIT"
-		},
 		"node_modules/babel-jest": {
 			"version": "29.7.0",
 			"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -3907,19 +4158,6 @@
 				"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
 			}
 		},
-		"node_modules/bs-logger": {
-			"version": "0.2.6",
-			"resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
-			"integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"fast-json-stable-stringify": "2.x"
-			},
-			"engines": {
-				"node": ">= 6"
-			}
-		},
 		"node_modules/bser": {
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
@@ -4880,22 +5118,6 @@
 				"readable-stream": "^2.0.2"
 			}
 		},
-		"node_modules/ejs": {
-			"version": "3.1.10",
-			"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
-			"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
-			"dev": true,
-			"license": "Apache-2.0",
-			"dependencies": {
-				"jake": "^10.8.5"
-			},
-			"bin": {
-				"ejs": "bin/cli.js"
-			},
-			"engines": {
-				"node": ">=0.10.0"
-			}
-		},
 		"node_modules/electron-to-chromium": {
 			"version": "1.5.123",
 			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.123.tgz",
@@ -5739,39 +5961,6 @@
 				"node": "^10.12.0 || >=12.0.0"
 			}
 		},
-		"node_modules/filelist": {
-			"version": "1.0.4",
-			"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
-			"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
-			"dev": true,
-			"license": "Apache-2.0",
-			"dependencies": {
-				"minimatch": "^5.0.1"
-			}
-		},
-		"node_modules/filelist/node_modules/brace-expansion": {
-			"version": "2.0.1",
-			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
-			"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"balanced-match": "^1.0.0"
-			}
-		},
-		"node_modules/filelist/node_modules/minimatch": {
-			"version": "5.1.6",
-			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
-			"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
-			"dev": true,
-			"license": "ISC",
-			"dependencies": {
-				"brace-expansion": "^2.0.1"
-			},
-			"engines": {
-				"node": ">=10"
-			}
-		},
 		"node_modules/fill-range": {
 			"version": "7.1.1",
 			"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -6739,58 +6928,6 @@
 				"node": ">=8"
 			}
 		},
-		"node_modules/jake": {
-			"version": "10.9.2",
-			"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
-			"integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
-			"dev": true,
-			"license": "Apache-2.0",
-			"dependencies": {
-				"async": "^3.2.3",
-				"chalk": "^4.0.2",
-				"filelist": "^1.0.4",
-				"minimatch": "^3.1.2"
-			},
-			"bin": {
-				"jake": "bin/cli.js"
-			},
-			"engines": {
-				"node": ">=10"
-			}
-		},
-		"node_modules/jake/node_modules/ansi-styles": {
-			"version": "4.3.0",
-			"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-			"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"color-convert": "^2.0.1"
-			},
-			"engines": {
-				"node": ">=8"
-			},
-			"funding": {
-				"url": "https://github.com/chalk/ansi-styles?sponsor=1"
-			}
-		},
-		"node_modules/jake/node_modules/chalk": {
-			"version": "4.1.2",
-			"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-			"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"ansi-styles": "^4.1.0",
-				"supports-color": "^7.1.0"
-			},
-			"engines": {
-				"node": ">=10"
-			},
-			"funding": {
-				"url": "https://github.com/chalk/chalk?sponsor=1"
-			}
-		},
 		"node_modules/java-properties": {
 			"version": "1.0.2",
 			"resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz",
@@ -7987,6 +8124,13 @@
 				"node": ">=6"
 			}
 		},
+		"node_modules/jsonc-parser": {
+			"version": "3.3.1",
+			"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+			"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/jsonfile": {
 			"version": "6.1.0",
 			"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
@@ -8408,13 +8552,6 @@
 			"dev": true,
 			"license": "MIT"
 		},
-		"node_modules/lodash.memoize": {
-			"version": "4.1.2",
-			"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
-			"integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
-			"dev": true,
-			"license": "MIT"
-		},
 		"node_modules/lodash.merge": {
 			"version": "4.6.2",
 			"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -8604,13 +8741,6 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
-		"node_modules/make-error": {
-			"version": "1.3.6",
-			"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
-			"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
-			"dev": true,
-			"license": "ISC"
-		},
 		"node_modules/makeerror": {
 			"version": "1.0.12",
 			"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -13803,55 +13933,6 @@
 				"typescript": ">=4.2.0"
 			}
 		},
-		"node_modules/ts-jest": {
-			"version": "29.2.6",
-			"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.6.tgz",
-			"integrity": "sha512-yTNZVZqc8lSixm+QGVFcPe6+yj7+TWZwIesuOWvfcn4B9bz5x4NDzVCQQjOs7Hfouu36aEqfEbo9Qpo+gq8dDg==",
-			"dev": true,
-			"license": "MIT",
-			"dependencies": {
-				"bs-logger": "^0.2.6",
-				"ejs": "^3.1.10",
-				"fast-json-stable-stringify": "^2.1.0",
-				"jest-util": "^29.0.0",
-				"json5": "^2.2.3",
-				"lodash.memoize": "^4.1.2",
-				"make-error": "^1.3.6",
-				"semver": "^7.7.1",
-				"yargs-parser": "^21.1.1"
-			},
-			"bin": {
-				"ts-jest": "cli.js"
-			},
-			"engines": {
-				"node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
-			},
-			"peerDependencies": {
-				"@babel/core": ">=7.0.0-beta.0 <8",
-				"@jest/transform": "^29.0.0",
-				"@jest/types": "^29.0.0",
-				"babel-jest": "^29.0.0",
-				"jest": "^29.0.0",
-				"typescript": ">=4.3 <6"
-			},
-			"peerDependenciesMeta": {
-				"@babel/core": {
-					"optional": true
-				},
-				"@jest/transform": {
-					"optional": true
-				},
-				"@jest/types": {
-					"optional": true
-				},
-				"babel-jest": {
-					"optional": true
-				},
-				"esbuild": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/tslib": {
 			"version": "2.8.1",
 			"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
diff --git a/package.json b/package.json
index b9ff21ed..b9507a2b 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,8 @@
 		"@semantic-release/github": "11.0.1",
 		"@semantic-release/npm": "12.0.1",
 		"@semantic-release/release-notes-generator": "14.0.3",
+		"@swc/core": "^1.10.18",
+		"@swc/jest": "^0.2.37",
 		"@types/jest": "29.5.14",
 		"@types/node": "22.13.11",
 		"@types/sinon": "17.0.4",
@@ -53,7 +55,6 @@
 		"prettier": "3.5.3",
 		"semantic-release": "24.2.3",
 		"sinon": "19.0.4",
-		"ts-jest": "29.2.6",
 		"typescript": "5.8.2",
 		"typescript-eslint": "7.18.0"
 	},
diff --git a/src/job.ts b/src/job.ts
index 00bb078b..bf5b7436 100644
--- a/src/job.ts
+++ b/src/job.ts
@@ -251,7 +251,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 	start() {
 		if (this._isActive) return;
 
-		const MAXDELAY = 2147483647; // The maximum number of milliseconds setTimeout will wait.
+		const MAXDELAY = 2147483647; // the maximum number of milliseconds setTimeout will wait.
 		let timeout = this.cronTime.getTimeout();
 		let remaining = 0;
 		let startTime: number;
@@ -264,7 +264,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 			}
 		};
 
-		// The callback wrapper checks if it needs to sleep another period or not
+		// the callback wrapper checks if it needs to sleep another period or not
 		// and does the real callback logic when it's time.
 		const callbackWrapper = () => {
 			const diff = startTime + timeout - Date.now();
@@ -279,7 +279,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				remaining += newTimeout;
 			}
 
-			// If there is sleep time remaining, calculate how long and go to sleep
+			// if there is sleep time remaining, calculate how long and go to sleep
 			// again. This processing might make us miss the deadline by a few ms
 			// times the number of sleep sessions. Given a MAXDELAY of almost a
 			// month, this should be no issue.
@@ -294,7 +294,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 
 				setCronTimeout(timeout);
 			} else {
-				// We have arrived at the correct point in time.
+				// we have arrived at the correct point in time.
 				this.lastExecution = new Date();
 
 				this._isActive = false;
@@ -309,7 +309,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		if (timeout >= 0) {
 			this._isActive = true;
 
-			// Don't try to sleep more than MAXDELAY ms at a time.
+			// don't try to sleep more than MAXDELAY ms at a time.
 
 			if (timeout > MAXDELAY) {
 				remaining = timeout - MAXDELAY;
@@ -343,7 +343,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 	}
 
 	/**
-	 * Stop the cronjob.
+	 * stop the cronjob.
 	 */
 	stop() {
 		if (this._timeout) clearTimeout(this._timeout);
diff --git a/src/time.ts b/src/time.ts
index 66892eef..b9fbcf2e 100644
--- a/src/time.ts
+++ b/src/time.ts
@@ -19,6 +19,15 @@ import {
 	TimeUnitField
 } from './types/cron.types';
 
+type CustomZone = Zone & {
+	zoneName?: string;
+	fixed?: string;
+};
+
+type CustomDateTime = Omit<DateTime, 'zone'> & {
+	zone: CustomZone;
+};
+
 export class CronTime {
 	source: string | DateTime;
 	timeZone?: string;
@@ -97,7 +106,7 @@ export class CronTime {
 	}
 
 	/**
-	 * Calculate the "next" scheduled time
+	 * calculate the "next" scheduled time
 	 */
 	sendAt(): DateTime;
 	sendAt(i: number): DateTime[];
@@ -105,7 +114,7 @@ export class CronTime {
 		let date =
 			this.realDate && this.source instanceof DateTime
 				? this.source
-				: DateTime.local();
+				: DateTime.utc();
 		if (this.timeZone) {
 			date = date.setZone(this.timeZone);
 		}
@@ -137,8 +146,9 @@ export class CronTime {
 		}
 
 		if (i === undefined || isNaN(i) || i < 0) {
+			const nextDate = this.getNextDateFrom(date);
 			// just get the next scheduled time
-			return this.getNextDateFrom(date);
+			return nextDate;
 		} else {
 			// return the next schedule times
 			const dates: DateTime[] = [];
@@ -152,10 +162,10 @@ export class CronTime {
 	}
 
 	/**
-	 * Get the number of milliseconds in the future at which to fire our callbacks.
+	 * get the number of milliseconds in the future at which to fire our callbacks.
 	 */
 	getTimeout() {
-		return Math.max(-1, this.sendAt().toMillis() - DateTime.local().toMillis());
+		return Math.max(-1, this.sendAt().toMillis() - DateTime.utc().toMillis());
 	}
 
 	/**
@@ -166,7 +176,7 @@ export class CronTime {
 	}
 
 	/**
-	 * Json representation of the parsed cron syntax.
+	 * json representation of the parsed cron syntax.
 	 */
 	toJSON() {
 		return TIME_UNITS.map(unit => {
@@ -175,13 +185,12 @@ export class CronTime {
 	}
 
 	/**
-	 * Get next date matching the specified cron time.
+	 * get next date matching the specified cron time.
 	 *
 	 * Algorithm:
-	 * - Start with a start date and a parsed crontime.
-	 * - Loop until 5 seconds have passed, or we found the next date.
+	 * - Start with a start date and a parsed CronTime.
 	 * - Within the loop:
-	 *   - If it took longer than 5 seconds to select a date, throw an exception.
+	 *   - If we can't find an execution time within 8 years, throw an exception.
 	 *   - Find the next month to run at.
 	 *   - Find the next day of the month to run at.
 	 *   - Find the next day of the week to run at.
@@ -191,15 +200,27 @@ export class CronTime {
 	 *   - Check that the chosen time does not equal the current execution.
 	 * - Return the selected date object.
 	 */
-	getNextDateFrom(start: Date | DateTime, timeZone?: string | Zone) {
+	getNextDateFrom(
+		start: Date | CustomDateTime,
+		timeZone?: string | CustomZone
+	): DateTime {
 		if (start instanceof Date) {
 			start = DateTime.fromJSDate(start);
 		}
-		let date = start;
-		const firstDate = start.toMillis();
 		if (timeZone) {
-			date = date.setZone(timeZone);
+			start = start.setZone(timeZone);
+		} else {
+			timeZone = start.zone.zoneName ?? start.zone.fixed;
 		}
+		// make a clone in UTC so we can manipulate it as if there were no time zones
+		let date = DateTime.fromFormat(
+			`${start.year}-${start.month}-${start.day} ${start.hour}:${start.minute}:${start.second}`,
+			'yyyy-M-d H:m:s',
+			{
+				zone: 'UTC'
+			}
+		);
+		const firstDate = date.toMillis();
 		if (!this.realDate) {
 			if (date.millisecond > 0) {
 				date = date.set({ millisecond: 0, second: date.second + 1 });
@@ -217,12 +238,9 @@ export class CronTime {
 		 * source: https://github.com/cronie-crond/cronie/blob/0d669551680f733a4bdd6bab082a0b3d6d7f089c/src/cronnext.c#L401-L403
 		 */
 		const maxMatch = DateTime.now().plus({ years: 8 });
-
 		// determine next date
 		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 		while (true) {
-			const diff = date.toMillis() - start.toMillis();
-
 			// hard stop if the current date is after the maximum match interval
 			if (date > maxMatch) {
 				throw new CronError(
@@ -240,74 +258,39 @@ export class CronTime {
 				!(date.month in this.month) &&
 				Object.keys(this.month).length !== 12
 			) {
-				date = date.plus({ months: 1 });
+				date = date.plus({ month: 1 });
 				date = date.set({ day: 1, hour: 0, minute: 0, second: 0 });
 
-				if (this._forwardDSTJump(0, 0, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
-				}
 				continue;
 			}
 
 			if (
-				!(date.day in this.dayOfMonth) &&
-				Object.keys(this.dayOfMonth).length !== 31 &&
-				!(
-					this._getWeekDay(date) in this.dayOfWeek &&
-					Object.keys(this.dayOfWeek).length !== 7
-				)
+				(!(date.day in this.dayOfMonth) &&
+					Object.keys(this.dayOfMonth).length !== 31 &&
+					!(
+						this._getWeekDay(date) in this.dayOfWeek &&
+						Object.keys(this.dayOfWeek).length !== 7
+					)) ||
+				(!(this._getWeekDay(date) in this.dayOfWeek) &&
+					Object.keys(this.dayOfWeek).length !== 7 &&
+					!(
+						date.day in this.dayOfMonth &&
+						Object.keys(this.dayOfMonth).length !== 31
+					))
 			) {
 				date = date.plus({ days: 1 });
 				date = date.set({ hour: 0, minute: 0, second: 0 });
 
-				if (this._forwardDSTJump(0, 0, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
-				}
-				continue;
-			}
-
-			if (
-				!(this._getWeekDay(date) in this.dayOfWeek) &&
-				Object.keys(this.dayOfWeek).length !== 7 &&
-				!(
-					date.day in this.dayOfMonth &&
-					Object.keys(this.dayOfMonth).length !== 31
-				)
-			) {
-				date = date.plus({ days: 1 });
-				date = date.set({ hour: 0, minute: 0, second: 0 });
-				if (this._forwardDSTJump(0, 0, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
-				}
 				continue;
 			}
 
 			if (!(date.hour in this.hour) && Object.keys(this.hour).length !== 24) {
-				const expectedHour =
-					date.hour === 23 && diff > 86400000 ? 0 : date.hour + 1;
-				const expectedMinute = date.minute; // expect no change.
+				// only allow the hour to be 24 if a day hasn't passed yet since we started calculating the new time
+				// otherwise we'll be changing the day here even though we already determined the correct day
 
-				date = date.set({ hour: expectedHour });
+				date = date.plus({ hour: 1 });
 				date = date.set({ minute: 0, second: 0 });
 
-				// When this is the case, Asking luxon to go forward by 1 hour actually made us go forward by more hours...
-				// This indicates that somewhere between these two time points, a forward DST adjustment has happened.
-				// When this happens, the job should be scheduled to execute as though the time has come when the jump is made.
-				// Therefore, the job should be scheduled on the first tick after the forward jump.
-				if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
-				}
-				// backwards jumps do not seem to have any problems (i.e. double activations),
-				// so they need not be handled in a similar way.
-
 				continue;
 			}
 
@@ -315,58 +298,20 @@ export class CronTime {
 				!(date.minute in this.minute) &&
 				Object.keys(this.minute).length !== 60
 			) {
-				const expectedMinute =
-					date.minute === 59 && diff > 3600000 ? 0 : date.minute + 1;
-				const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0);
-
-				date = date.set({ minute: expectedMinute });
+				date = date.plus({ minute: 1 });
 				date = date.set({ second: 0 });
 
-				// Same case as with hours: DST forward jump.
-				// This must be accounted for if a minute increment pushed us to a jumping point.
-				if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
-				}
-
 				continue;
 			}
 
+			// respond to the previous date being checked by advancing a second
+			// just like when we advance seconds normally
 			if (
-				!(date.second in this.second) &&
-				Object.keys(this.second).length !== 60
+				date.toMillis() === firstDate ||
+				(!(date.second in this.second) &&
+					Object.keys(this.second).length !== 60)
 			) {
-				const expectedSecond =
-					date.second === 59 && diff > 60000 ? 0 : date.second + 1;
-				const expectedMinute = date.minute + (expectedSecond === 60 ? 1 : 0);
-				const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0);
-
-				date = date.set({ second: expectedSecond });
-
-				// Seconds can cause it too, imagine 21:59:59 -> 23:00:00.
-				if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
-				}
-
-				continue;
-			}
-
-			if (date.toMillis() === firstDate) {
-				const expectedSecond = date.second + 1;
-				const expectedMinute = date.minute + (expectedSecond === 60 ? 1 : 0);
-				const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0);
-
-				date = date.set({ second: expectedSecond });
-
-				// Same as always.
-				if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
-				}
+				date = date.plus({ second: 1 });
 
 				continue;
 			}
@@ -374,250 +319,62 @@ export class CronTime {
 			break;
 		}
 
-		return date;
-	}
-
-	/**
-	 * Search backwards in time 1 minute at a time, to detect a DST forward jump.
-	 * When the jump is found, the range of the jump is investigated to check for acceptable cron times.
-	 *
-	 * A pair is returned, whose first is a boolean representing if an acceptable time was found inside the jump,
-	 * and whose second is a DateTime representing the first millisecond after the jump.
-	 *
-	 * The input date is expected to be decently close to a DST jump.
-	 * Up to a day in the past is checked before an error is thrown.
-	 * @param date
-	 * @return [boolean, DateTime]
-	 */
-	private _findPreviousDSTJump(date: DateTime): [boolean, DateTime] {
-		/** @type number */
-		let expectedMinute, expectedHour, actualMinute, actualHour;
-		/** @type DateTime */
-		let maybeJumpingPoint = date;
-
-		// representing one day of backwards checking. If this is hit, the input must be wrong.
-		const iterationLimit = 60 * 24;
-		let iteration = 0;
-		do {
-			if (++iteration > iterationLimit) {
-				throw new CronError(
-					`ERROR: This DST checking related function assumes the input DateTime (${
-						date.toISO() ?? date.toMillis()
-					}) is within 24 hours of a DST jump.`
-				);
-			}
+		// handle cases where time jumps forward due to Daylight Savings
 
-			expectedMinute = maybeJumpingPoint.minute - 1;
-			expectedHour = maybeJumpingPoint.hour;
+		const expectedHour = date.hour;
+		const expectedMinute = date.minute;
 
-			if (expectedMinute < 0) {
-				expectedMinute += 60;
-				expectedHour = (expectedHour + 24 - 1) % 24; // Subtract 1 hour, but we must account for the -1 case.
+		date = DateTime.fromFormat(
+			`${date.year}-${date.month}-${date.day} ${date.hour}:${date.minute}:${date.second}`,
+			'yyyy-M-d H:m:s',
+			{
+				zone: timeZone
 			}
+		);
+		const nonDSTReferenceDate = DateTime.fromFormat(
+			`${date.year}-1-1 0:0:0`,
+			'yyyy-M-d H:m:s',
+			{ zone: timeZone }
+		);
 
-			maybeJumpingPoint = maybeJumpingPoint.minus({ minute: 1 });
-
-			actualMinute = maybeJumpingPoint.minute;
-			actualHour = maybeJumpingPoint.hour;
-		} while (expectedMinute === actualMinute && expectedHour === actualHour);
-
-		// Setting the seconds and milliseconds to zero is necessary for two reasons:
-		// Firstly, the range checking function needs the earliest moment after the jump.
-		// Secondly, this DateTime may be used for scheduling jobs, if there existed a job in the skipped range.
-		const afterJumpingPoint = maybeJumpingPoint
-			.plus({ minute: 1 }) // back to the first minute _after_ the jump
-			.set({ second: 0, millisecond: 0 });
-
-		// Get the lower bound of the range to check as well. This only has to be accurate down to minutes.
-		const beforeJumpingPoint = afterJumpingPoint.minus({ second: 1 });
-
+		// if the hour or minute is different from expected and
+		// if date we assume to not be under daylight savings has a different offset
+		// rewind until just after the offset
 		if (
-			date.month in this.month &&
-			date.day in this.dayOfMonth &&
-			this._getWeekDay(date) in this.dayOfWeek
+			(expectedHour !== date.hour || expectedMinute !== date.minute) &&
+			nonDSTReferenceDate.offset !== date.offset
 		) {
-			return [
-				this._checkTimeInSkippedRange(beforeJumpingPoint, afterJumpingPoint),
-				afterJumpingPoint
-			];
-		}
-
-		// no valid time in the range for sure, units that didn't change from the skip mismatch.
-		return [false, afterJumpingPoint];
-	}
-
-	/**
-	 * Given 2 DateTimes, which represent 1 second before and immediately after a DST forward jump,
-	 * checks if a time in the skipped range would have been a valid CronJob time.
-	 *
-	 * Could technically work with just one of these values, extracting the other by adding or subtracting seconds.
-	 * However, this couples the input DateTime to actually being tied to a DST jump,
-	 * which would make the function harder to test.
-	 * This way the logic just tests a range of minutes and hours, regardless if there are skipped time points underneath.
-	 *
-	 * Assumes the DST jump started no earlier than 0:00 and jumped forward by at least 1 minute, to at most 23:59.
-	 * i.e. The day is assumed constant, but the jump is not assumed to be an hour long.
-	 * Empirically, it is almost always one hour, but very, very rarely 30 minutes.
-	 *
-	 * Assumes dayOfWeek, dayOfMonth and month match all match, so only the hours, minutes and seconds are to be checked.
-	 * @param {DateTime} beforeJumpingPoint
-	 * @param {DateTime} afterJumpingPoint
-	 * @returns {boolean}
-	 */
-	private _checkTimeInSkippedRange(
-		beforeJumpingPoint: DateTime,
-		afterJumpingPoint: DateTime
-	) {
-		// start by getting the first minute & hour inside the skipped range.
-		const startingMinute = (beforeJumpingPoint.minute + 1) % 60;
-		const startingHour =
-			(beforeJumpingPoint.hour + (startingMinute === 0 ? 1 : 0)) % 24;
-
-		const hourRangeSize = afterJumpingPoint.hour - startingHour + 1;
-		const isHourJump = startingMinute === 0 && afterJumpingPoint.minute === 0;
-
-		// There exist DST jumps other than 1 hour long, and the function is built to deal with it.
-		// It may be overkill to assume some cases, but it shouldn't cost much at runtime.
-		// https://en.wikipedia.org/wiki/Daylight_saving_time_by_country
-		if (hourRangeSize === 2 && isHourJump) {
-			// Exact 1 hour jump, most common real-world case.
-			// There is no need to check minutes and seconds, as any value would suffice.
-			return startingHour in this.hour;
-		} else if (hourRangeSize === 1) {
-			// less than 1 hour jump, rare but does exist.
-			return (
-				startingHour in this.hour &&
-				this._checkTimeInSkippedRangeSingleHour(
-					startingMinute,
-					afterJumpingPoint.minute
-				)
-			);
-		} else {
-			// non-round or multi-hour jump. (does not exist in the real world at the time of writing)
-			return this._checkTimeInSkippedRangeMultiHour(
-				startingHour,
-				startingMinute,
-				afterJumpingPoint.hour,
-				afterJumpingPoint.minute
-			);
-		}
-	}
-
-	/**
-	 * Component of checking if a CronJob time existed in a DateTime range skipped by DST.
-	 * This subroutine makes a further assumption that the skipped range is fully contained in one hour,
-	 * and that all other larger units are valid for the job.
-	 *
-	 * for example a jump from 02:00:00 to 02:30:00, but not from 02:00:00 to 03:00:00.
-	 * @see _checkTimeInSkippedRange
-	 *
-	 * This is done by checking if any minute in startMinute - endMinute is valid, excluding endMinute.
-	 * For endMinute, there is only a match if the 0th second is a valid time.
-	 */
-	private _checkTimeInSkippedRangeSingleHour(
-		startMinute: number,
-		endMinute: number
-	) {
-		for (let minute = startMinute; minute < endMinute; ++minute) {
-			if (minute in this.minute) return true;
+			while (date.minus({ minute: 1 }).offset !== nonDSTReferenceDate.offset) {
+				date = date.minus({ minute: 1 });
+			}
+			return date;
 		}
 
-		// Unless the very last second of the jump matched, there is no match.
-		return endMinute in this.minute && 0 in this.second;
-	}
+		// handle cases where time jumps back due to Daylight Savings (ambiguous times)
 
-	/**
-	 * Component of checking if a CronJob time existed in a DateTime range skipped by DST.
-	 * This subroutine assumes the jump touches at least 2 hours, but the jump does not necessarily fully contain these hours.
-	 *
-	 * @see _checkTimeInSkippedRange
-	 *
-	 * This is done by defining the minutes to check for the first and last hour,
-	 * and checking all 60 minutes for any hours in between them.
-	 *
-	 * If any hour x minute combination is a valid time, true is returned.
-	 * The endMinute x endHour combination is only checked with the 0th second, since the rest would be out of the range.
-	 *
-	 * @param startHour {number}
-	 * @param startMinute {number}
-	 * @param endHour {number}
-	 * @param endMinute {number}
-	 */
-	private _checkTimeInSkippedRangeMultiHour(
-		startHour: number,
-		startMinute: number,
-		endHour: number,
-		endMinute: number
-	) {
-		if (startHour >= endHour) {
-			throw new CronError(
-				`ERROR: This DST checking related function assumes the forward jump starting hour (${startHour}) is less than the end hour (${endHour})`
-			);
+		// daylight savings jumps are either 60 or 30 minutes
+		const hourTestDate = date.minus({ hour: 1 });
+		const twoHourTestDate = date.minus({ hour: 2 });
+		// if the previous hour is the same as this hour we are in an ambiguous time
+		// jump back to the earlier hour as long as it's not in the past
+		if (
+			(hourTestDate.hour === date.hour ||
+				twoHourTestDate.hour === hourTestDate.hour) &&
+			hourTestDate > start
+		) {
+			date = hourTestDate;
 		}
-
-		/** @type number[] */
-		const firstHourMinuteRange = Array.from(
-			{ length: 60 - startMinute },
-			(_, k) => startMinute + k
-		);
-		/** @type {number[]} The final minute is not contained on purpose. Every minute in this range represents one for which any second is valid. */
-		const lastHourMinuteRange = Array.from({ length: endMinute }, (_, k) => k);
-		/** @type number[] */
-		const middleHourMinuteRange = Array.from({ length: 60 }, (_, k) => k);
-
-		/** @type (number) => number[] */
-		const selectRange = (forHour: number) => {
-			if (forHour === startHour) {
-				return firstHourMinuteRange;
-			} else if (forHour === endHour) {
-				return lastHourMinuteRange;
-			} else {
-				return middleHourMinuteRange;
-			}
-		};
-
-		// Include the endHour: Selecting the right range still ensures no values outside the skip are checked.
-		for (let hour = startHour; hour <= endHour; ++hour) {
-			if (!(hour in this.hour)) continue;
-
-			// The hour matches, so if the minute is in the range, we have a match!
-			const usingRange = selectRange(hour);
-
-			for (const minute of usingRange) {
-				// All minutes in any of the selected ranges represent minutes which are fully contained in the jump,
-				// So we need not check the seconds. If the minute is in there, it is a match.
-				if (minute in this.minute) return true;
-			}
+		// similar for half hour jumps
+		const halfHourTestDate = date.minus({ minute: 30 });
+		if (
+			(halfHourTestDate.minute === date.minute ||
+				hourTestDate.minute === halfHourTestDate.minute) &&
+			halfHourTestDate > start
+		) {
+			date = halfHourTestDate;
 		}
 
-		// The endMinute of the endHour was not checked in the loop, because only the 0th second of it is in the range.
-		// Arriving here means no match was found yet, but this final check may turn up as a match.
-		return endHour in this.hour && endMinute in this.minute && 0 in this.second;
-	}
-
-	/**
-	 * Given expected and actual hours and minutes, report if a DST forward jump occurred.
-	 *
-	 * This is the case when the expected is smaller than the acutal.
-	 *
-	 * It is not sufficient to check only hours, because some parts of the world apply DST by shifting in minutes.
-	 * Better to account for it by checking minutes too, before an Australian of Lord Howe Island call us.
-	 * @param expectedHour
-	 * @param expectedMinute
-	 * @param {DateTime} actualDate
-	 */
-	private _forwardDSTJump(
-		expectedHour: number,
-		expectedMinute: number,
-		actualDate: DateTime
-	) {
-		const actualHour = actualDate.hour;
-		const actualMinute = actualDate.minute;
-
-		const didHoursJumped = expectedHour % 24 < actualHour;
-		const didMinutesJumped = expectedMinute % 60 < actualMinute;
-
-		return didHoursJumped || didMinutesJumped;
+		return date;
 	}
 
 	/**
@@ -652,7 +409,7 @@ export class CronTime {
 	}
 
 	/**
-	 * Parse the cron syntax into something useful for selecting the next execution time.
+	 * parse the cron syntax into something useful for selecting the next execution time.
 	 *
 	 * Algorithm:
 	 * - Replace preset
@@ -691,9 +448,9 @@ export class CronTime {
 		const unitsLen = units.length;
 		for (const unit of TIME_UNITS) {
 			const i = TIME_UNITS.indexOf(unit);
-			// If the split source string doesn't contain all digits,
+			// if the split source string doesn't contain all digits,
 			// assume defaults for first n missing digits.
-			// This adds support for 5-digit standard cron syntax
+			// this adds support for 5-digit standard cron syntax
 			const cur =
 				units[i - (TIME_UNITS_LEN - unitsLen)] ?? PARSE_DEFAULTS[unit];
 			this._parseField(cur, unit);
@@ -701,10 +458,10 @@ export class CronTime {
 	}
 
 	/**
-	 * Parse individual field from the cron syntax provided.
+	 * parse individual field from the cron syntax provided.
 	 *
 	 * Algorithm:
-	 * - Split field by commas aand check for wildcards to ensure proper user.
+	 * - Split field by commas and check for wildcards to ensure proper user.
 	 * - Replace wildcard values with <low>-<high> boundaries.
 	 * - Split field by commas and then iterate over ranges inside field.
 	 *   - If range matches pattern then map over matches using replace (to parse the range by the regex pattern)
@@ -761,18 +518,18 @@ export class CronTime {
 					throw new CronError(`Field value (${value}) is out of range`);
 				}
 
-				// Positive integer higher than constraints[0]
+				// positive integer higher than constraints[0]
 				lower = Math.min(Math.max(low, ~~Math.abs(lower)), high);
 
-				// Positive integer lower than constraints[1]
+				// positive integer lower than constraints[1]
 				if (upper !== undefined) {
 					upper = Math.min(high, ~~Math.abs(upper));
 				} else {
-					// If step is provided, the default upper range is the highest value
+					// if step is provided, the default upper range is the highest value
 					upper = wasStepDefined ? high : lower;
 				}
 
-				// Count from the lower barrier to the upper
+				// count from the lower barrier to the upper
 				// forcing type cast here since we checked above that
 				// we are between constraint bounds
 				pointer = lower as typeof pointer;
diff --git a/tests/cron.fuzz.ts b/tests/cron.fuzz.ts
index 64662f86..33cf7b46 100644
--- a/tests/cron.fuzz.ts
+++ b/tests/cron.fuzz.ts
@@ -1,6 +1,6 @@
 /* eslint-disable jest/no-standalone-expect */
 import { fc, test } from '@fast-check/jest';
-import { CronJob } from '../src';
+import { CronJob } from '../src/job';
 import { CronError } from '../src/errors';
 
 /**
@@ -30,7 +30,9 @@ function testCronJob(
 	},
 	checkError: (err: unknown) => boolean
 ) {
-	// console.debug(cronTime, '|', timeZone, '|', utcOffset);
+	// console.debug(
+	// 	`${cronTime} | ${start} | ${timeZone} | ${runOnInit} | ${utcOffset} | ${unrefTimeout} | ${tzOrOffset}`
+	// );
 	try {
 		const job = new CronJob(
 			cronTime,
diff --git a/tests/cron.test.ts b/tests/cron.test.ts
index 1de92ffe..7a63a3c2 100644
--- a/tests/cron.test.ts
+++ b/tests/cron.test.ts
@@ -437,23 +437,23 @@ describe('cron', () => {
 		it('should run a job using cron syntax with a timezone', () => {
 			const clock = sinon.useFakeTimers();
 			let zone = 'America/Chicago';
-			// New Orleans time
+			// new Orleans time
 			let t = DateTime.local().setZone(zone);
-			// Current time
+			// current time
 			const d = DateTime.local();
 
-			// If current time is New Orleans time, switch to Los Angeles..
+			// if current time is New Orleans time, switch to Los Angeles..
 			if (t.hour === d.hour) {
 				zone = 'America/Los_Angeles';
 				t = t.setZone(zone);
 			}
 			expect(d.hour).not.toBe(t.hour);
 
-			// If t = 59s12m then t.setSeconds(60)
+			// if t = 59s12m then t.setSeconds(60)
 			// becomes 00s13m so we're fine just doing
 			// this and no testRun callback.
 			t = t.plus({ seconds: 1 });
-			// Run a job designed to be executed at a given
+			// run a job designed to be executed at a given
 			// time in `zone`, making sure that it is a different
 			// hour than local time.
 			const job = new CronJob(
@@ -472,25 +472,25 @@ describe('cron', () => {
 		it('should run a job using cron syntax with a "UTC+HH:mm" offset as timezone', () => {
 			const clock = sinon.useFakeTimers();
 
-			// Current time
+			// current time
 			const d = DateTime.local();
 
-			// Current time with zone offset
+			// current time with zone offset
 			let zone = 'UTC+5:30';
 			let t = DateTime.local().setZone(zone);
 
-			// If current offset is UTC+5:30, switch to UTC+6:30..
+			// if current offset is UTC+5:30, switch to UTC+6:30..
 			if (t.hour === d.hour && t.minute === d.minute) {
 				zone = 'UTC+6:30';
 				t = t.setZone(zone);
 			}
 			expect(`${d.hour}:${d.minute}`).not.toBe(`${t.hour}:${t.minute}`);
 
-			// If t = 59s12m then t.setSeconds(60)
+			// if t = 59s12m then t.setSeconds(60)
 			// becomes 00s13m so we're fine just doing
 			// this and no testRun callback.
 			t = t.plus({ seconds: 1 });
-			// Run a job designed to be executed at a given
+			// run a job designed to be executed at a given
 			// time in `zone`, making sure that it is a different
 			// hour than local time.
 			const job = new CronJob(
@@ -508,12 +508,12 @@ describe('cron', () => {
 
 		it('should run a job using a date', () => {
 			let zone = 'America/Chicago';
-			// New Orleans time
+			// new Orleans time
 			let t = DateTime.local().setZone(zone);
-			// Current time
+			// current time
 			let d = DateTime.local();
 
-			// If current time is New Orleans time, switch to Los Angeles..
+			// if current time is New Orleans time, switch to Los Angeles..
 			if (t.hour === d.hour) {
 				zone = 'America/Los_Angeles';
 				t = t.setZone(zone);
@@ -628,6 +628,21 @@ describe('cron', () => {
 		}).toThrow();
 	});
 
+	it('should not get into an infinite loop when using uncommon offsets', () => {
+		const job = new CronJob(
+			'* * * * * *',
+			function () {},
+			null,
+			true,
+			null,
+			null,
+			true,
+			-4
+		);
+		expect(job.isActive).toBe(true);
+		job.stop();
+	});
+
 	it('should not throw if at least one time is valid', () => {
 		expect(() => {
 			const job = new CronJob('0 0 30 JAN,FEB *', callback, null, true);
@@ -636,21 +651,20 @@ describe('cron', () => {
 	});
 
 	it('should test start of month', () => {
-		const d = new Date('12/31/2014');
-		d.setSeconds(59);
-		d.setMinutes(59);
-		d.setHours(23);
+		const d = new Date(Date.UTC(2014, 12, 31, 23, 59, 59));
 		const clock = sinon.useFakeTimers(d.getTime());
 
 		const job = new CronJob('0 0 0 1 * *', callback, null, true);
 
+		// first millisecond of January
 		clock.tick(1001);
 		expect(callback).toHaveBeenCalledTimes(1);
-
-		clock.tick(2678399001);
+		// 2 ms less than 28 days; last millisecond of February
+		clock.tick(28 * 24 * 60 * 60 * 1000 - 2);
 		expect(callback).toHaveBeenCalledTimes(1);
 
-		clock.tick(2678400001); // jump over 2 firsts
+		// 1 ms more than 31 days; jump over 2 firsts
+		clock.tick(31 * 24 * 60 * 60 * 1000 + 1);
 		clock.restore();
 		job.stop();
 
@@ -670,7 +684,7 @@ describe('cron', () => {
 		job.stop();
 	});
 
-	it('should run every day', () => {
+	it('should run every day at 3:59:59', () => {
 		const d = new Date('12/31/2014');
 		d.setSeconds(59);
 		d.setMinutes(59);
@@ -837,7 +851,7 @@ describe('cron', () => {
 	 * source: https://github.com/cronie-crond/cronie/blob/0d669551680f733a4bdd6bab082a0b3d6d7f089c/src/cronnext.c#L401-L403
 	 */
 	it('should work correctly for max match interval', () => {
-		const d = new Date(2096, 2, 1);
+		const d = new Date(Date.UTC(2096, 2, 1));
 		const clock = sinon.useFakeTimers(d.getTime());
 
 		const job = CronJob.from({
@@ -861,9 +875,9 @@ describe('cron', () => {
 	describe('with utcOffset', () => {
 		it('should run a job using cron syntax with number format utcOffset', () => {
 			const clock = sinon.useFakeTimers();
-			// Current time
+			// current time
 			const t = DateTime.local();
-			// UTC Offset decreased by an hour
+			// uTC Offset decreased by an hour
 			const utcOffset = t.offset - 60;
 
 			const job = new CronJob(
@@ -888,10 +902,10 @@ describe('cron', () => {
 
 		it('should run a job using cron syntax with numeric format utcOffset with minute support', () => {
 			const clock = sinon.useFakeTimers();
-			// Current time
+			// current time
 			const t = DateTime.local();
 
-			// UTC Offset decreased by 45 minutes
+			// uTC Offset decreased by 45 minutes
 			const utcOffset = t.offset - 45;
 			const job = new CronJob(
 				`${t.second} ${t.minute} ${t.hour} * * *`,
@@ -1066,7 +1080,7 @@ describe('cron', () => {
 	it('should automatically setup a new timeout if we roll past the max timeout delay', () => {
 		const clock = sinon.useFakeTimers();
 		const d = new Date();
-		d.setMilliseconds(2147485647 * 2); // MAXDELAY in `job.js` + 2000.
+		d.setMilliseconds(2147485647 * 2); // mAXDELAY in `job.js` + 2000.
 		const job = new CronJob(d, callback);
 		job.start();
 		clock.tick(2147483648);
@@ -1089,7 +1103,7 @@ describe('cron', () => {
 	it('should give the correct last execution date for intervals greater than 25 days (#710)', () => {
 		const clock = sinon.useFakeTimers();
 
-		const job = new CronJob('0 0 0 1 * *', callback); // At 00:00 on day-of-month 1.
+		const job = new CronJob('0 0 0 1 * *', callback); // at 00:00 on day-of-month 1.
 		job.start();
 
 		// tick one tick before nextDate()
@@ -1216,11 +1230,11 @@ describe('cron', () => {
 
 			expect(job.isCallbackRunning).toBe(false);
 
-			// First execution
+			// first execution
 			await clock.tickAsync(2000);
 			expect(job.isCallbackRunning).toBe(true);
 
-			// Wait for job completion
+			// wait for job completion
 			await clock.tickAsync(500);
 			expect(isJobCompleted).toBe(false);
 			expect(job.isCallbackRunning).toBe(true);
@@ -1256,7 +1270,7 @@ describe('cron', () => {
 
 			expect(job.isCallbackRunning).toBe(false);
 
-			// First execution
+			// first execution
 			clock.tick(1000);
 			expect(isJobCompleted).toBe(false);
 			expect(job.isCallbackRunning).toBe(false);
@@ -1344,8 +1358,6 @@ describe('cron', () => {
 			}).toJSDate();
 			const clock = sinon.useFakeTimers(d.getTime());
 
-			console.debug({ d });
-
 			const job = new CronJob(
 				'0 1 30 3 *',
 				callback,
@@ -1367,8 +1379,6 @@ describe('cron', () => {
 			}).toJSDate();
 			const clock = sinon.useFakeTimers(d.getTime());
 
-			console.debug({ d });
-
 			const job = new CronJob(
 				'0 2 31 3 *',
 				callback,
@@ -1383,5 +1393,29 @@ describe('cron', () => {
 			clock.restore();
 			job.stop();
 		});
+
+		it('should still execute at the desired interval when the time changes back one hour', () => {
+			// there are two instances of 2 am. Setting to an earlier time so it is not ambiguous
+			// see https://moment.github.io/luxon/#/zones?id=ambiguous-times
+			const d = DateTime.fromISO('2024-04-07T01:45:00.000', {
+				zone: 'Australia/Melbourne'
+			}).toJSDate();
+			const clock = sinon.useFakeTimers(d.getTime());
+
+			const job = new CronJob(
+				'*/30 * * * *',
+				callback,
+				null,
+				true,
+				'Australia/Melbourne'
+			);
+
+			clock.tick(1000 * 60 * 60 * 3);
+
+			expect(callback).toHaveBeenCalledTimes(6);
+
+			clock.restore();
+			job.stop();
+		});
 	});
 });
diff --git a/tests/crontime.test.ts b/tests/crontime.test.ts
index 22f5acf5..67dac9cd 100644
--- a/tests/crontime.test.ts
+++ b/tests/crontime.test.ts
@@ -151,9 +151,9 @@ describe('crontime', () => {
 		const sunday0 = new CronTime('* * * * 0', null, null);
 		const sunday7 = new CronTime('* * * * 7', null, null);
 
-		// @ts-expect-error deleting for comparaison purposes
+		// @ts-expect-error deleting for comparison purposes
 		delete sunday0.source;
-		// @ts-expect-error deleting for comparaison purposes
+		// @ts-expect-error deleting for comparison purposes
 		delete sunday7.source;
 
 		expect(sunday7).toEqual(sunday0);
@@ -289,7 +289,7 @@ describe('crontime', () => {
 
 	it('should test next real date', () => {
 		const initialDate = new Date();
-		initialDate.setDate(initialDate.getDate() + 1); // In other case date will be in the past
+		initialDate.setDate(initialDate.getDate() + 1); // in other case date will be in the past
 		const ct = new CronTime(initialDate);
 
 		const nextDate = new Date();
@@ -418,17 +418,17 @@ describe('crontime', () => {
 			previousDate = nextDate;
 		}
 	});
-	it('should work around time zone changes that shifts time back (1)', () => {
+	it('should work around offset changes that shift time back (1)', () => {
 		const d = new Date('10-7-2018');
-		// America/Sao_Paulo has a time zone change around NOV 3 2018.
+		// america/Sao_Paulo has a offset change around NOV 3 2018.
 		const cronTime = new CronTime('0 0 9 4 * *');
 		const nextDate = cronTime.getNextDateFrom(d, 'America/Sao_Paulo');
-		expect(nextDate.valueOf()).toEqual(
-			DateTime.fromISO('2018-11-04T09:00:00.000-02:00').valueOf()
+		expect(nextDate.setZone('America/Sao_Paulo').toISO()).toEqual(
+			'2018-11-04T09:00:00.000-02:00'
 		);
 	});
-	it('should work around time zone changes that shifts time back (2)', () => {
-		// Asia/Amman DST ends in  26 - OCT-2018 (-1 to hours)
+	it('should work around offset changes that shift time back (2)', () => {
+		// asia/Amman DST ends in  26 - OCT-2018 (-1 to hours)
 		const currentDate = DateTime.fromISO('2018-10-25T23:00', {
 			zone: 'Asia/Amman'
 		});
@@ -439,8 +439,8 @@ describe('crontime', () => {
 		});
 		expect(nextDate.toMillis() - expectedDate.toMillis()).toBe(0);
 	});
-	it('should work around time zone changes that shifts time forward', () => {
-		// Asia/Amman DST starts in  30-March-2018 (+1 to hours)
+	it('should work around offset changes that shifts time forward', () => {
+		// asia/Amman DST starts in  30-March-2018 (+1 to hours)
 		let currentDate = DateTime.fromISO('2018-03-29T23:00', {
 			zone: 'Asia/Amman'
 		});
@@ -451,7 +451,33 @@ describe('crontime', () => {
 			currentDate = nextDate;
 		}
 	});
-	it('Should schedule jobs inside time zone changes that shifts time forward to the end of the shift, for weekly jobs', () => {
+	it('should not execute immediately if conditions have not been met during forward DST jump', () => {
+		// europe/Paris DST starts at 30 Mar 2025, 02:00 (+1 to hours)
+		let currentDate = DateTime.fromISO('2025-03-30T01:59', {
+			zone: 'Europe/Paris'
+		});
+		const cronTime = new CronTime('20 4 * * *');
+		const nextDate = cronTime.getNextDateFrom(currentDate, 'Europe/Paris');
+		expect(nextDate.toString()).toEqual(
+			DateTime.fromISO('2025-03-30T04:20', { zone: 'Europe/Paris' }).toString()
+		);
+	});
+	it('Should schedule jobs inside offset changes when started exactly one month before, for monthly jobs', () => {
+		// there is a DST jump on March 9 at midnight
+		let currentDate = DateTime.fromISO('2025-02-09T00:30:00', {
+			zone: 'Cuba'
+		});
+		const cronTime = new CronTime('0 0 9 * *');
+		let nextDate = cronTime.getNextDateFrom(currentDate, 'Cuba');
+		expect(nextDate.toISO()).toEqual(
+			// 28 days minus half an hour since we jump forward
+			DateTime.fromMillis(
+				currentDate.toMillis() + 1000 * 60 * 60 * 24 * 28 - 1000 * 60 * 30,
+				{ zone: 'Cuba' }
+			).toISO()
+		);
+	});
+	it('Should schedule jobs inside offset changes that shifts time forward to the end of the shift, for weekly jobs', () => {
 		let currentDate = DateTime.fromISO('2018-03-29T23:15', {
 			zone: 'Asia/Amman'
 		});
@@ -473,7 +499,7 @@ describe('crontime', () => {
 			1000 * 3600 * 24 * 7
 		);
 	});
-	it('Should schedule jobs inside time zone changes that shifts the time forward to the end of the shift, for daily jobs', () => {
+	it('Should schedule jobs inside offset changes that shifts the time forward to the end of the shift, for daily jobs', () => {
 		let currentDate = DateTime.fromISO('2018-03-29T23:45', {
 			zone: 'Asia/Amman'
 		});
@@ -495,27 +521,30 @@ describe('crontime', () => {
 			1000 * 3600 * 24
 		);
 	});
-	it('Should schedule jobs inside time zone changes that shifts the time forward to the end of the shift, for hourly jobs', () => {
+	it('Should schedule jobs inside offset changes that shifts the time forward to the end of the shift, for hourly jobs', () => {
 		let currentDate = DateTime.fromISO('2018-03-29T23:45', {
 			zone: 'Asia/Amman'
 		});
 		const cronTime = new CronTime('30 * * * *'); // the next 0:30 is March 30th, but it will jump from 0:00 to 1:00.
 		let nextDate = cronTime.getNextDateFrom(currentDate, 'Asia/Amman');
-		expect(nextDate.toMillis() - currentDate.toMillis()).toEqual(
-			1000 * 60 * 15
-		); // 15 minutes is 30T00:00, which jumps to 1:00 which is past the trigger of 0:30.
+		// 15 minutes is 30T00:00, which jumps to 1:00 which is past the trigger of 0:30.
+		expect(nextDate.toISO()).toEqual(
+			currentDate.plus({ milliseconds: 1000 * 60 * 15 }).toISO()
+		);
 		// the next one is at 1:30, so 30m.
 		currentDate = nextDate;
 		nextDate = cronTime.getNextDateFrom(currentDate);
-		expect(nextDate.toMillis() - currentDate.toMillis()).toEqual(
-			1000 * 60 * 30
+		expect(nextDate.toISO()).toEqual(
+			currentDate.plus({ milliseconds: 1000 * 60 * 30 }).toISO()
 		);
 		// back to normal.
 		currentDate = nextDate;
 		nextDate = cronTime.getNextDateFrom(currentDate);
-		expect(nextDate.toMillis() - currentDate.toMillis()).toEqual(1000 * 3600);
+		expect(nextDate.toISO()).toEqual(
+			currentDate.plus({ milliseconds: 1000 * 60 * 60 }).toISO()
+		);
 	});
-	it('Should schedule jobs inside time zone changes that shifts the time forward to the end of the shift, for minutely jobs', () => {
+	it('Should schedule jobs inside offset changes that shifts the time forward to the end of the shift, for minutely jobs', () => {
 		let currentDate = DateTime.fromISO('2018-03-29T23:59', {
 			zone: 'Asia/Amman'
 		});
@@ -527,115 +556,6 @@ describe('crontime', () => {
 		nextDate = cronTime.getNextDateFrom(currentDate);
 		expect(nextDate.toMillis() - currentDate.toMillis()).toEqual(1000 * 60);
 	});
-	// Do not think a similar test for secondly job is necessary, the minutely one already ensured no double hits in the overlap zone.
-	it('Should throw when dates that are not within 24 hours of DST jumps are checked for past DST jumps', () => {
-		const cronTime = new CronTime('* * * * *');
-		const tryFindPastDST = (isoTime: string) => () => {
-			const maybeBadDate = DateTime.fromISO(isoTime, {
-				zone: 'Asia/Amman'
-			});
-			expect(maybeBadDate.isValid).toBe(true);
-			// @ts-expect-error testing private property
-			cronTime._findPreviousDSTJump(maybeBadDate);
-		};
-
-		// This timezone jumps from 0:00 to 1:00 on March 30th, so the cutoff is March 31st 1:00:00
-		expect(tryFindPastDST('2018-03-31T00:59:00')).not.toThrow();
-		expect(tryFindPastDST('2018-03-31T01:00:00')).toThrow();
-	});
-	// The following few DST related tests do not need specific dates that are actually DST,
-	// the functions they are calling assume the given parameters encapsulate a DST jump,
-	// and use the raw hour and minute data to check it from there.
-	it('Should correctly scan time periods as if they are DST jumps, half hour jumps', () => {
-		let endDate = DateTime.fromISO('2023-01-01T16:00:00.000', {
-			zone: 'Europe/Amsterdam'
-		});
-		let startDate = endDate.minus({ minute: 30, second: 1 });
-		const cronTime = new CronTime('5 16 * * *'); // at 16:05:00.
-		// @ts-expect-error testing private property
-		let isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(false);
-
-		endDate = endDate.plus({ minute: 30 }); // 16:30:00
-		startDate = endDate.minus({ minute: 30, second: 1 }); // 15:59:59
-		// @ts-expect-error testing private property
-		isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(true);
-	});
-	it('Should not include seconds in the minute after the DST jump as part of the jump scan', () => {
-		const endDate = DateTime.fromISO('2023-01-01T16:00:00.000', {
-			zone: 'Europe/Amsterdam'
-		});
-		// 1 hour jump case
-		let startDate = endDate.minus({ hour: 1, second: 1 });
-		const cronTime = new CronTime('1 5 16 * * *'); // at 16:00:01.
-		// @ts-expect-error testing private property
-		let isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(false);
-		// 'quirky' jump case
-		startDate = endDate.minus({ hour: 1, minute: 45, second: 1 });
-		// @ts-expect-error testing private property
-		isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(false);
-	});
-	it('Should correctly scan time periods as if they are DST jumps, full hour jumps', () => {
-		let endDate = DateTime.fromISO('2023-01-01T16:00:00.000', {
-			zone: 'Europe/Amsterdam'
-		});
-		let startDate = endDate.minus({ hour: 1, second: 1 });
-		const cronTime = new CronTime('5 16 * * *'); // at 16:05:00.
-		// @ts-expect-error testing private property
-		let isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(false);
-
-		endDate = endDate.plus({ hour: 1 }); // 17:00:00
-		startDate = endDate.minus({ hour: 1, second: 1 }); // 15:59:59
-		// @ts-expect-error testing private property
-		isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(true);
-	});
-	// A 'quirky' DST jump is one that should not break the implementation, but does not exist in real life (yet)
-	it('Should correctly scan time periods as if they are DST jumps, quirky jumps (1)', () => {
-		// Testing a jump that is less than an hour long, but wraps around an hour.
-		let endDate = DateTime.fromISO('2023-01-01T16:15:00.000', {
-			zone: 'Europe/Amsterdam'
-		});
-		let startDate = endDate.minus({ minute: 45, second: 1 }); // 15:29:59
-		const cronTime = new CronTime('30 16 * * *'); // at 16:30:00.
-		// @ts-expect-error testing private property
-		let isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(false);
-
-		endDate = endDate.plus({ minute: 30 }); // 16:45:00
-		startDate = endDate.minus({ minute: 50, second: 1 }); // 15:54:59
-		// @ts-expect-error testing private property
-		isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(true);
-	});
-	it('Should correctly scan time periods as if they are DST jumps, quirky jumps (2)', () => {
-		// Testing a jump that is over an hour long.
-		let endDate = DateTime.fromISO('2023-01-01T16:15:00.000', {
-			zone: 'Europe/Amsterdam'
-		});
-		let startDate = endDate.minus({ hour: 3, minute: 45, second: 1 }); // 12:29:59
-		const cronTime = new CronTime('30 16 * * *'); // at 16:30:00.
-		// @ts-expect-error testing private property
-		let isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(false);
-
-		endDate = endDate.plus({ minute: 30 }); // 16:45:00
-		startDate = endDate.minus({ hour: 3, minute: 45, second: 1 }); // 12:59:59
-		// @ts-expect-error testing private property
-		isJobInRange = cronTime._checkTimeInSkippedRange(startDate, endDate);
-		expect(isJobInRange).toBe(true);
-	});
-	it('Enforces the hour difference assumption for handling multi-hour DST jumps', () => {
-		const cronTime = new CronTime('30 16 * * *');
-		expect(() => {
-			// @ts-expect-error testing private property
-			cronTime._checkTimeInSkippedRangeMultiHour(15, 0, 15, 30);
-		}).toThrow();
-	});
 	it('should generate the right N next days for * * * * *', () => {
 		const cronTime = new CronTime('* * * * *');
 		let currentDate = DateTime.local().set({ second: 0, millisecond: 0 });
@@ -707,9 +627,7 @@ describe('crontime', () => {
 		const cronTime = new CronTime('*/15 * * FEB *');
 		const previousDate = new Date(Date.UTC(2018, 3, 0, 0, 0));
 		const nextDate = cronTime.getNextDateFrom(previousDate, 'UTC');
-		expect(nextDate.valueOf()).toEqual(
-			new Date(Date.UTC(2019, 1, 1, 0, 0)).valueOf()
-		);
+		expect(nextDate.toISO()).toEqual('2019-02-01T00:00:00.000Z');
 	});
 	it('should generate the right next day when cron is set to both day of the month and day of the week (1)', () => {
 		const cronTime = new CronTime('0 8 1 * 4');


Reviews:
user: login: sheerlox
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T11:56:36Z
user: login: sheerlox
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T13:59:11Z
user: login: intcreator
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T17:32:53Z
user: login: intcreator
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T22:57:58Z
user: login: intcreator
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T22:58:28Z
user: login: sheerlox
body: lgtm, great work!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-26T19:10:30Z
user: login: sheerlox
body: lgtm, great work!
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-03-26T19:10:34Z

Review comments:
diff_hunk: @@ -259,13 +253,19 @@ export class CronTime {
 					Object.keys(this.dayOfWeek).length !== 7
 				)
 			) {
+				const preDayChangeDate = date;
 				date = date.plus({ days: 1 });
 				date = date.set({ hour: 0, minute: 0, second: 0 });
 
-				if (this._forwardDSTJump(0, 0, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
+				if (offset !== date.offset) {
+					offset = date.offset;
+					if (
+						date.hour - preDayChangeDate.hour > 1 ||
+						(date.hour === 1 && preDayChangeDate.hour === 23)
+					) {
+						// We skipped past a valid execution time and must execute immediately
+						break;
+					}
path: src/time.ts
user: login: sheerlox
body: Why is the second check necessary? Wouldn't an offset change always mean a DST forward jump?

Also, maybe adding back a `_forwardDSTJump` with a signature like `(preChangeDate: DateTime, date: DateTime, minutes?: boolean): boolean` to avoid duplication would be nice.
created_at: 2025-03-06T11:49:26Z
author_association: COLLABORATOR
diff_hunk: @@ -451,7 +451,18 @@ describe('crontime', () => {
 			currentDate = nextDate;
 		}
 	});
-	it('Should schedule jobs inside time zone changes that shifts time forward to the end of the shift, for weekly jobs', () => {
+	it('should not execute immediately if conditions have not been met during forward DST jump', () => {
+		// Europe/Paris DST starts at 30 Mar 2025, 02:00 (+1 to hours)
+		let currentDate = DateTime.fromISO('2025-03-30T01:59', {
+			zone: 'Europe/Paris'
+		});
+		const cronTime = new CronTime('20 4 * * *');
+		const nextDate = cronTime.getNextDateFrom(currentDate, 'Europe/Paris');
+		expect(nextDate.toString()).toEqual(
+			DateTime.fromISO('2025-03-30T04:20', { zone: 'Europe/Paris' }).toString()
+		);
+	});
path: tests/crontime.test.ts
user: login: sheerlox
body: While reviewing the PR, I had a hunch that because we are not verifying whether an execution was missed and returning the correct execution time like the previous `_forwardDSTJump` did, we would always end up executing when a jump happens, even though no execution is planned during the jump.

I've added a test case to verify this thought, and this is indeed happening:

![image](https://github.com/user-attachments/assets/27f1683c-f23f-4123-8a0f-5ffb11e74a27)

created_at: 2025-03-06T11:54:43Z
author_association: COLLABORATOR
diff_hunk: @@ -451,7 +451,18 @@ describe('crontime', () => {
 			currentDate = nextDate;
 		}
 	});
-	it('Should schedule jobs inside time zone changes that shifts time forward to the end of the shift, for weekly jobs', () => {
+	it('should not execute immediately if conditions have not been met during forward DST jump', () => {
+		// Europe/Paris DST starts at 30 Mar 2025, 02:00 (+1 to hours)
+		let currentDate = DateTime.fromISO('2025-03-30T01:59', {
+			zone: 'Europe/Paris'
+		});
+		const cronTime = new CronTime('20 4 * * *');
+		const nextDate = cronTime.getNextDateFrom(currentDate, 'Europe/Paris');
+		expect(nextDate.toString()).toEqual(
+			DateTime.fromISO('2025-03-30T04:20', { zone: 'Europe/Paris' }).toString()
+		);
+	});
path: tests/crontime.test.ts
user: login: sheerlox
body: I have cherry-picked it on the main branch for confirmation, and it doesn't happen there.
created_at: 2025-03-06T11:55:11Z
author_association: COLLABORATOR
diff_hunk: @@ -259,13 +253,19 @@ export class CronTime {
 					Object.keys(this.dayOfWeek).length !== 7
 				)
 			) {
+				const preDayChangeDate = date;
 				date = date.plus({ days: 1 });
 				date = date.set({ hour: 0, minute: 0, second: 0 });
 
-				if (this._forwardDSTJump(0, 0, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
+				if (offset !== date.offset) {
+					offset = date.offset;
+					if (
+						date.hour - preDayChangeDate.hour > 1 ||
+						(date.hour === 1 && preDayChangeDate.hour === 23)
+					) {
+						// We skipped past a valid execution time and must execute immediately
+						break;
+					}
path: src/time.ts
user: login: sheerlox
body: Not saying it isn't necessary per say, just that I can't seem to wrap my head around it and would like to understand haha
created_at: 2025-03-06T13:59:10Z
author_association: COLLABORATOR
diff_hunk: @@ -451,7 +451,18 @@ describe('crontime', () => {
 			currentDate = nextDate;
 		}
 	});
-	it('Should schedule jobs inside time zone changes that shifts time forward to the end of the shift, for weekly jobs', () => {
+	it('should not execute immediately if conditions have not been met during forward DST jump', () => {
+		// Europe/Paris DST starts at 30 Mar 2025, 02:00 (+1 to hours)
+		let currentDate = DateTime.fromISO('2025-03-30T01:59', {
+			zone: 'Europe/Paris'
+		});
+		const cronTime = new CronTime('20 4 * * *');
+		const nextDate = cronTime.getNextDateFrom(currentDate, 'Europe/Paris');
+		expect(nextDate.toString()).toEqual(
+			DateTime.fromISO('2025-03-30T04:20', { zone: 'Europe/Paris' }).toString()
+		);
+	});
path: tests/crontime.test.ts
user: login: intcreator
body: yeah I think I might have been too aggressive removing that function. I was just so excited to detect a jump by checking if `offset` changed. I might add it back or a similar helper function that still uses `offset` to track down the exact time that the change happens
created_at: 2025-03-06T17:32:53Z
author_association: COLLABORATOR
diff_hunk: @@ -451,7 +451,18 @@ describe('crontime', () => {
 			currentDate = nextDate;
 		}
 	});
-	it('Should schedule jobs inside time zone changes that shifts time forward to the end of the shift, for weekly jobs', () => {
+	it('should not execute immediately if conditions have not been met during forward DST jump', () => {
+		// Europe/Paris DST starts at 30 Mar 2025, 02:00 (+1 to hours)
+		let currentDate = DateTime.fromISO('2025-03-30T01:59', {
+			zone: 'Europe/Paris'
+		});
+		const cronTime = new CronTime('20 4 * * *');
+		const nextDate = cronTime.getNextDateFrom(currentDate, 'Europe/Paris');
+		expect(nextDate.toString()).toEqual(
+			DateTime.fromISO('2025-03-30T04:20', { zone: 'Europe/Paris' }).toString()
+		);
+	});
path: tests/crontime.test.ts
user: login: intcreator
body: fixed so your test case passes now! thanks for adding that
created_at: 2025-03-07T22:57:58Z
author_association: COLLABORATOR
diff_hunk: @@ -259,13 +253,19 @@ export class CronTime {
 					Object.keys(this.dayOfWeek).length !== 7
 				)
 			) {
+				const preDayChangeDate = date;
 				date = date.plus({ days: 1 });
 				date = date.set({ hour: 0, minute: 0, second: 0 });
 
-				if (this._forwardDSTJump(0, 0, date)) {
-					const [isDone, newDate] = this._findPreviousDSTJump(date);
-					date = newDate;
-					if (isDone) break;
+				if (offset !== date.offset) {
+					offset = date.offset;
+					if (
+						date.hour - preDayChangeDate.hour > 1 ||
+						(date.hour === 1 && preDayChangeDate.hour === 23)
+					) {
+						// We skipped past a valid execution time and must execute immediately
+						break;
+					}
path: src/time.ts
user: login: intcreator
body: resolving as outdated; I went with a different method here
created_at: 2025-03-07T22:58:28Z
author_association: COLLABORATOR

PR comments:
user: login: intcreator
created_at: 2025-03-13T05:16:03Z
body: I added a couple of custom types to get around a weird issue where Definitely Typed didn't provide all the subproperties on the `Zone` object that I needed. should those definitions go in one of the type files? they're only used in time.ts for now...
author_association: COLLABORATOR
user: login: ncb000gt
created_at: 2025-03-26T19:29:52Z
body: :tada: This PR is included in version 4.1.1 :tada:

The release is available on:
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/cron/v/4.1.1)
- [GitHub release](https://github.com/kelektiv/node-cron/releases/tag/v4.1.1)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: MEMBER