Repository: bradymholt/cRonstrue
Pull request #345 (2025-04-08 11:15:26)

Fix: Incorrect Multiple Range Hour Interpretation (#294)
This merge request addresses the issue described in #294, where multiple hour ranges in a cron expression were not being interpreted correctly. Specifically, the end of each hour range was incorrectly formatted as :00 instead of :59 for the last minute of the range.

All tests have been run and passed successfully after applying this fix. If the proposed solution does not align with your vision for the library or if you prefer a different approach, feel free to close this merge request or update it as you see fit. Thank you for maintaining this great library!

bradymholt: This looks great - thank you for this PR!

Patch:
diff --git a/src/expressionDescriptor.ts b/src/expressionDescriptor.ts
index 7c62981..c955cd9 100644
--- a/src/expressionDescriptor.ts
+++ b/src/expressionDescriptor.ts
@@ -6,7 +6,7 @@ import { Locale } from "./i18n/locale";
 import { LocaleLoader } from "./i18n/localeLoader";
 
 export class ExpressionDescriptor {
-  static locales: { [name: string]: Locale; } = {};
+  static locales: { [name: string]: Locale } = {};
   static defaultLocale: string;
   static specialCharacters: string[];
 
@@ -265,11 +265,32 @@ export class ExpressionDescriptor {
 
   protected getHoursDescription() {
     let expression = this.expressionParts[2];
+
+    // Collect the end values of hour ranges (e.g., "7-19" -> "19") to determine if ":59" should be used instead of ":00" for the last minute of the range.
+    let hourIndex = 0;
+    const rangeEndValues: { value: string; index: number }[] = [];
+    expression
+      .split("/")[0] // Ignore step values (e.g., "7-19/2" -> "7-19")
+      .split(",") // Split multiple ranges or values (e.g., "7-19,20" -> ["7-19", "20"])
+      .forEach((range) => {
+        const rangeParts = range.split("-"); // Split ranges into start and end (e.g., "7-19" -> ["7", "19"])
+        if (rangeParts.length === 2) {
+          rangeEndValues.push({ value: rangeParts[1], index: hourIndex + 1 });
+        }
+        hourIndex += rangeParts.length;
+      });
+
+    let evaluationIndex = 0;
     let description = this.getSegmentDescription(
       expression,
       this.i18n.everyHour(),
       (s) => {
-        return this.formatTime(s, "0", "");
+        const match = rangeEndValues.find((r) => r.value === s && r.index === evaluationIndex);
+        const isRangeEndWithNonZeroMinute = match && this.expressionParts[1] !== "0";
+
+        evaluationIndex++;
+
+        return isRangeEndWithNonZeroMinute ? this.formatTime(s, "59", "") : this.formatTime(s, "0", "");
       },
       (s) => {
         return StringUtilities.format(this.i18n.everyX0Hours(s), s);
@@ -282,18 +303,6 @@ export class ExpressionDescriptor {
       }
     );
 
-    // If this is an hour range and minute segment is not "on the hour" (0), we'll change the second hour part from :00 to :59
-    if (description && expression.includes("-") && this.expressionParts[1] != "0") {
-      const atTheHourMatches = Array.from(description.matchAll(/:00/g));
-      if (atTheHourMatches.length > 1) {
-        const lastAtTheHourMatchIndex = atTheHourMatches[atTheHourMatches.length - 1].index;
-        description =
-          description.substring(0, lastAtTheHourMatchIndex) +
-          ":59" +
-          description.substring(lastAtTheHourMatchIndex! + 3);
-      }
-    }
-
     return description;
   }
 


Test patch:
diff --git a/test/cronstrue.ts b/test/cronstrue.ts
index ace7fa9..341940b 100644
--- a/test/cronstrue.ts
+++ b/test/cronstrue.ts
@@ -669,6 +669,20 @@ describe("Cronstrue", function () {
         "Every 10 minutes, minutes 5 through 45 past the hour, every 5 minutes, and at 9 minutes past the hour"
       );
     });
+
+    it("*/5 0-4,6-8 * * *", function () {
+      assert.equal(
+        cronstrue.toString(this.test?.title as string),
+        "Every 5 minutes, at 12:00 AM through 04:59 AM and 06:00 AM through 08:59 AM"
+      );
+    });
+
+    it("* 1-2,22-23 * * *", function () {
+      assert.equal(
+        cronstrue.toString(this.test?.title as string),
+        "Every minute, at 01:00 AM through 02:59 AM and 10:00 PM through 11:59 PM"
+      );
+    });
   });
 
   describe("@ expressions", function () {