Fast and tiny JavaScript/TypeScript cron parser with timezone support
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add 'describe' in readable form for cron expressions

+503 -4
+13 -1
README.md
··· 122 122 // Returns: { minute: [0], hour: [9], day: [1, 2, ..., 31], month: [0, 1, 2, ..., 11], weekday: [1,2,3,4,5] } 123 123 ``` 124 124 125 + ### `describe(expression)` 126 + 127 + Get a human-readable description of a cron expression. 128 + 129 + ```typescript 130 + describe("*/5 * * * *"); // "Every 5 minutes" 131 + describe("0 9 * * 1-5"); // "At minute 0, between 9 AM and 5 PM, on weekdays" 132 + describe("*/15 3,4 1-4 */3 6"); 133 + // "Every 15 minutes, at 3 AM, 4 AM, on days 1-4 of the month or on Saturday, every 3 months" 134 + ``` 135 + 125 136 ## Cron Expression Format 126 137 127 138 ``` ··· 165 176 166 177 | Import | Raw | Minified | Gzipped | 167 178 | ------------------------------------------------------ | -------- | -------- | ----------- | 168 - | `Full bundle (all exports) ` | 14.22 KB | 6.16 KB | **2.35 KB** | 179 + | `Full bundle (all exports) ` | 19.65 KB | 8.80 KB | **3.28 KB** | 169 180 | `nextRun only ` | 12.64 KB | 5.43 KB | **2.12 KB** | 170 181 | `previousRun only ` | 12.65 KB | 5.43 KB | **2.12 KB** | 171 182 | `nextRuns only ` | 13.03 KB | 5.58 KB | **2.18 KB** | 172 183 | `isValid only ` | 4.00 KB | 1.81 KB | **951 B** | 173 184 | `parse only ` | 3.89 KB | 1.76 KB | **926 B** | 185 + | `describe only ` | 9.30 KB | 4.36 KB | **1.84 KB** | 174 186 | `isMatch only ` | 5.59 KB | 2.54 KB | **1.22 KB** | 175 187 | `Validation only (isValid + parse) ` | 4.01 KB | 1.81 KB | **952 B** | 176 188 | `Scheduling only (nextRun + previousRun + nextRuns) ` | 13.52 KB | 5.83 KB | **2.20 KB** |
+8 -1
examples/basic.ts
··· 1 - import { nextRun, previousRun, nextRuns, isValid, parse } from "../src/index.js"; 1 + import { nextRun, previousRun, nextRuns, isValid, parse, describe } from "../src/index.js"; 2 2 3 3 console.log("=== cron-fast Examples ===\n"); 4 4 ··· 45 45 console.log(` Minutes: ${parsed.minute}`); 46 46 console.log(` Hours: ${parsed.hour}`); 47 47 console.log(` Weekdays: ${parsed.weekday} (Mon-Fri)\n`); 48 + 49 + // Example 8: Human-readable descriptions 50 + console.log("8. Human-readable descriptions:"); 51 + console.log(` "*/5 * * * *" → ${describe("*/5 * * * *")}`); 52 + console.log(` "0 9 * * 1-5" → ${describe("0 9 * * 1-5")}`); 53 + console.log(` "30 14 * * *" → ${describe("30 14 * * *")}`); 54 + console.log(` "0 */6 * * *" → ${describe("0 */6 * * *")}\n`); 48 55 49 56 console.log("=== Performance Test ===\n"); 50 57 const iterations = 10000;
+1 -1
jsr.json
··· 1 1 { 2 2 "name": "@kbilkis/cron-fast", 3 - "version": "0.1.3", 3 + "version": "0.2.0", 4 4 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 5 5 "keywords": [ 6 6 "javascript",
+1 -1
package.json
··· 1 1 { 2 2 "name": "cron-fast", 3 - "version": "0.1.3", 3 + "version": "0.2.0", 4 4 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 5 5 "keywords": [ 6 6 "browser",
+4
scripts/bundle-size.ts
··· 44 44 code: `import { parse } from "${indexPath}";\nconsole.log(parse);`, 45 45 }, 46 46 { 47 + name: "describe only", 48 + code: `import { describe } from "${indexPath}";\nconsole.log(describe);`, 49 + }, 50 + { 47 51 name: "isMatch only", 48 52 code: `import { isMatch } from "${indexPath}";\nconsole.log(isMatch);`, 49 53 },
+273
src/describe.ts
··· 1 + import type { ParsedCron } from "./types.js"; 2 + import { parse } from "./parser.js"; 3 + 4 + const MONTH_NAMES = [ 5 + "January", 6 + "February", 7 + "March", 8 + "April", 9 + "May", 10 + "June", 11 + "July", 12 + "August", 13 + "September", 14 + "October", 15 + "November", 16 + "December", 17 + ]; 18 + 19 + const WEEKDAY_NAMES = [ 20 + "Sunday", 21 + "Monday", 22 + "Tuesday", 23 + "Wednesday", 24 + "Thursday", 25 + "Friday", 26 + "Saturday", 27 + ]; 28 + 29 + /** 30 + * Generate a human-readable description of a cron expression 31 + */ 32 + export function describe(expression: string): string { 33 + const parsed: ParsedCron = parse(expression); 34 + 35 + const parts: string[] = []; 36 + 37 + // Check if we can use simple time format (specific minute and hour) 38 + const hasSpecificTime = parsed.minute.length === 1 && parsed.hour.length === 1; 39 + const hasMultipleSpecificTimes = 40 + parsed.minute.length === 1 && parsed.hour.length > 1 && parsed.hour.length <= 5; 41 + 42 + if (hasSpecificTime) { 43 + // Use "At HH:MM AM/PM" format 44 + parts.push(`At ${formatTime(parsed.hour[0], parsed.minute[0])}`); 45 + } else if (hasMultipleSpecificTimes) { 46 + // Use "At HH:MM AM/PM, HH:MM AM/PM, ..." format for multiple specific times 47 + const times = parsed.hour.map((h) => formatTime(h, parsed.minute[0])); 48 + parts.push(`At ${times.join(", ")}`); 49 + } else { 50 + // Minute 51 + parts.push(describeMinute(parsed.minute)); 52 + 53 + // Hour 54 + if (parsed.hour.length < 24) { 55 + parts.push(describeHour(parsed.hour)); 56 + } 57 + } 58 + 59 + // Day and Weekday (handle OR logic) 60 + const dayPart = describeDay(parsed.day, parsed.dayIsWildcard); 61 + const weekdayPart = describeWeekday(parsed.weekday, parsed.weekdayIsWildcard); 62 + 63 + if (dayPart && weekdayPart) { 64 + parts.push(`${dayPart} or ${weekdayPart}`); 65 + } else if (dayPart) { 66 + parts.push(dayPart); 67 + } else if (weekdayPart) { 68 + parts.push(weekdayPart); 69 + } 70 + 71 + // Month 72 + if (parsed.month.length < 12) { 73 + parts.push(describeMonth(parsed.month)); 74 + } 75 + 76 + return parts.join(", "); 77 + } 78 + 79 + function describeMinute(minutes: number[]): string { 80 + if (minutes.length === 60) { 81 + return "Every minute"; 82 + } 83 + 84 + if (minutes.length === 1) { 85 + return `At minute ${minutes[0]}`; 86 + } 87 + 88 + // Check for step pattern (must start at 0 and cover significant range) 89 + const step = detectStep(minutes, 0, 59); 90 + if (step && minutes[0] === 0 && step > 1) { 91 + return `Every ${step} minutes`; 92 + } 93 + 94 + // Check for range 95 + if (isConsecutive(minutes) && minutes.length > 2) { 96 + return `At minutes ${minutes[0]}-${minutes[minutes.length - 1]}`; 97 + } 98 + 99 + // List specific minutes 100 + return `At minutes ${formatList(minutes)}`; 101 + } 102 + 103 + function describeHour(hours: number[]): string { 104 + if (hours.length === 1) { 105 + return `at ${formatHour(hours[0])}`; 106 + } 107 + 108 + // Check for step pattern (must start at 0 and cover significant range) 109 + const step = detectStep(hours, 0, 23); 110 + if (step && hours[0] === 0 && hours.length >= 4) { 111 + if (step === 1) { 112 + return "every hour"; 113 + } 114 + return `every ${step} hours`; 115 + } 116 + 117 + // Check for range (only if more than 2 consecutive hours) 118 + if (isConsecutive(hours) && hours.length > 2) { 119 + return `between ${formatHour(hours[0])} and ${formatHour(hours[hours.length - 1])}`; 120 + } 121 + 122 + // List specific hours 123 + const hourStrings = hours.map(formatHour); 124 + if (hourStrings.length <= 5) { 125 + return `at ${hourStrings.join(", ")}`; 126 + } 127 + return `at ${hourStrings.slice(0, 5).join(", ")}, and ${hourStrings.length - 5} more`; 128 + } 129 + 130 + function describeDay(days: number[], isWildcard: boolean): string { 131 + if (isWildcard || days.length === 31) { 132 + return ""; 133 + } 134 + 135 + if (days.length === 1) { 136 + return `on day ${days[0]} of the month`; 137 + } 138 + 139 + // Check for step pattern (must start at 1 for days) 140 + const step = detectStep(days, 1, 31); 141 + if (step && days[0] === 1 && days.length >= 4) { 142 + if (step === 1) { 143 + return "every day"; 144 + } 145 + return `every ${step} days`; 146 + } 147 + 148 + // Check for range (only if more than 2 consecutive days) 149 + if (isConsecutive(days) && days.length > 1) { 150 + return `on days ${days[0]}-${days[days.length - 1]} of the month`; 151 + } 152 + 153 + // List specific days 154 + return `on days ${formatList(days)} of the month`; 155 + } 156 + 157 + function describeWeekday(weekdays: number[], isWildcard: boolean): string { 158 + if (isWildcard || weekdays.length === 7) { 159 + return ""; 160 + } 161 + 162 + if (weekdays.length === 1) { 163 + return `on ${WEEKDAY_NAMES[weekdays[0]]}`; 164 + } 165 + 166 + // Check for weekdays (Mon-Fri) 167 + if ( 168 + weekdays.length === 5 && 169 + weekdays.includes(1) && 170 + weekdays.includes(2) && 171 + weekdays.includes(3) && 172 + weekdays.includes(4) && 173 + weekdays.includes(5) 174 + ) { 175 + return "on weekdays"; 176 + } 177 + 178 + // Check for weekend 179 + if (weekdays.length === 2 && weekdays.includes(0) && weekdays.includes(6)) { 180 + return "on weekends"; 181 + } 182 + 183 + // Check for range 184 + if (isConsecutive(weekdays)) { 185 + return `on ${WEEKDAY_NAMES[weekdays[0]]}-${WEEKDAY_NAMES[weekdays[weekdays.length - 1]]}`; 186 + } 187 + 188 + // List specific weekdays 189 + return `on ${weekdays.map((d) => WEEKDAY_NAMES[d]).join(", ")}`; 190 + } 191 + 192 + function describeMonth(months: number[]): string { 193 + if (months.length === 1) { 194 + return `in ${MONTH_NAMES[months[0]]}`; 195 + } 196 + 197 + // Check for step pattern (must start at 0 for months since they're 0-indexed) 198 + const step = detectStep(months, 0, 11); 199 + if (step && months[0] === 0 && months.length >= 3) { 200 + if (step === 1) { 201 + return "every month"; 202 + } 203 + return `every ${step} months`; 204 + } 205 + 206 + // Check for range (only if more than 2 consecutive months) 207 + if (isConsecutive(months) && months.length > 1) { 208 + return `in ${MONTH_NAMES[months[0]]}-${MONTH_NAMES[months[months.length - 1]]}`; 209 + } 210 + 211 + // List specific months 212 + return `in ${months.map((m) => MONTH_NAMES[m]).join(", ")}`; 213 + } 214 + 215 + function formatHour(hour: number): string { 216 + if (hour === 0) return "12 AM"; 217 + if (hour === 12) return "12 PM"; 218 + if (hour < 12) return `${hour} AM`; 219 + return `${hour - 12} PM`; 220 + } 221 + 222 + function formatTime(hour: number, minute: number): string { 223 + const h = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; 224 + const period = hour < 12 ? "AM" : "PM"; 225 + const m = minute.toString().padStart(2, "0"); 226 + return `${h}:${m} ${period}`; 227 + } 228 + 229 + function formatList(numbers: number[]): string { 230 + if (numbers.length <= 3) { 231 + return numbers.join(", "); 232 + } 233 + return `${numbers.slice(0, 3).join(", ")}, and ${numbers.length - 3} more`; 234 + } 235 + 236 + function detectStep(values: number[], min: number, max: number): number | null { 237 + if (values.length < 2) return null; 238 + 239 + const step = values[1] - values[0]; 240 + if (step <= 0) return null; 241 + 242 + // Check if all values follow the step pattern 243 + for (let i = 0; i < values.length - 1; i++) { 244 + if (values[i + 1] - values[i] !== step) { 245 + return null; 246 + } 247 + } 248 + 249 + // Only return step if it starts from min and is a complete step pattern 250 + // A complete step pattern goes from min to as close to max as possible 251 + if (values[0] === min) { 252 + // Calculate the expected last value for a complete step pattern 253 + const expectedLast = min + Math.floor((max - min) / step) * step; 254 + // Check if the last value matches what we'd expect from */step 255 + if (values[values.length - 1] === expectedLast) { 256 + return step; 257 + } 258 + } 259 + 260 + return null; 261 + } 262 + 263 + function isConsecutive(values: number[]): boolean { 264 + if (values.length < 2) return false; 265 + 266 + for (let i = 0; i < values.length - 1; i++) { 267 + if (values[i + 1] - values[i] !== 1) { 268 + return false; 269 + } 270 + } 271 + 272 + return true; 273 + }
+1
src/index.ts
··· 5 5 6 6 export { nextRun, previousRun, nextRuns, isMatch } from "./scheduler.js"; 7 7 export { parse, isValid } from "./parser.js"; 8 + export { describe } from "./describe.js"; 8 9 export type { CronOptions, ParsedCron } from "./types.js";
+202
test/describe.test.ts
··· 1 + import { describe as describeCron } from "../src/describe.js"; 2 + import { describe, it, expect } from "vitest"; 3 + 4 + describe("describe", () => { 5 + it("should describe simple expressions", () => { 6 + expect(describeCron("*/5 * * * *")).toBe("Every 5 minutes"); 7 + expect(describeCron("0 * * * *")).toBe("At minute 0"); 8 + expect(describeCron("0 0 * * *")).toBe("At 12:00 AM"); 9 + expect(describeCron("0 12 * * *")).toBe("At 12:00 PM"); 10 + expect(describeCron("30 14 * * *")).toBe("At 2:30 PM"); 11 + expect(describeCron("15 9 * * *")).toBe("At 9:15 AM"); 12 + }); 13 + 14 + it("should describe complex minute patterns", () => { 15 + expect(describeCron("*/15 * * * *")).toBe("Every 15 minutes"); 16 + expect(describeCron("0,15,30,45 * * * *")).toBe("Every 15 minutes"); // Same as */15 17 + expect(describeCron("5-10 * * * *")).toBe("At minutes 5-10"); 18 + expect(describeCron("0,5,10 * * * *")).toBe("At minutes 0, 5, 10"); // Not a complete step pattern 19 + }); 20 + 21 + it("should describe hour patterns", () => { 22 + expect(describeCron("0 */2 * * *")).toBe("At minute 0, every 2 hours"); 23 + expect(describeCron("0 9-17 * * *")).toBe("At minute 0, between 9 AM and 5 PM"); 24 + expect(describeCron("0 3,4 * * *")).toBe("At 3:00 AM, 4:00 AM"); 25 + }); 26 + 27 + it("should describe day patterns", () => { 28 + expect(describeCron("0 0 1 * *")).toBe("At 12:00 AM, on day 1 of the month"); 29 + expect(describeCron("0 0 1-4 * *")).toBe("At 12:00 AM, on days 1-4 of the month"); 30 + expect(describeCron("0 0 */5 * *")).toBe("At 12:00 AM, every 5 days"); 31 + }); 32 + 33 + it("should describe weekday patterns", () => { 34 + expect(describeCron("0 0 * * 0")).toBe("At 12:00 AM, on Sunday"); 35 + expect(describeCron("0 0 * * 1-5")).toBe("At 12:00 AM, on weekdays"); 36 + expect(describeCron("0 0 * * 0,6")).toBe("At 12:00 AM, on weekends"); 37 + expect(describeCron("0 0 * * 6")).toBe("At 12:00 AM, on Saturday"); 38 + }); 39 + 40 + it("should describe month patterns", () => { 41 + expect(describeCron("0 0 1 1 *")).toBe("At 12:00 AM, on day 1 of the month, in January"); 42 + expect(describeCron("0 0 1 */3 *")).toBe("At 12:00 AM, on day 1 of the month, every 3 months"); 43 + expect(describeCron("0 0 1 1-6 *")).toBe("At 12:00 AM, on day 1 of the month, in January-June"); 44 + }); 45 + 46 + it("should describe day OR weekday logic", () => { 47 + expect(describeCron("0 0 1 * 1")).toBe("At 12:00 AM, on day 1 of the month or on Monday"); 48 + expect(describeCron("0 0 15 * 5")).toBe("At 12:00 AM, on day 15 of the month or on Friday"); 49 + }); 50 + 51 + it("should describe the complex example", () => { 52 + expect(describeCron("*/15 3,4 1-4 */3 6")).toBe( 53 + "Every 15 minutes, at 3 AM, 4 AM, on days 1-4 of the month or on Saturday, every 3 months", 54 + ); 55 + }); 56 + 57 + it("should describe common cron patterns", () => { 58 + expect(describeCron("0 0 * * *")).toBe("At 12:00 AM"); 59 + expect(describeCron("0 0 * * 1")).toBe("At 12:00 AM, on Monday"); 60 + expect(describeCron("30 2 * * *")).toBe("At 2:30 AM"); 61 + expect(describeCron("0 */6 * * *")).toBe("At 12:00 AM, 6:00 AM, 12:00 PM, 6:00 PM"); 62 + expect(describeCron("0 6,12,18 * * *")).toBe("At 6:00 AM, 12:00 PM, 6:00 PM"); 63 + }); 64 + 65 + it("should handle wildcards correctly", () => { 66 + expect(describeCron("* * * * *")).toBe("Every minute"); 67 + expect(describeCron("0 * * * *")).toBe("At minute 0"); 68 + expect(describeCron("* 0 * * *")).toBe("Every minute, at 12 AM"); 69 + }); 70 + 71 + it("should describe complex real-world scenarios", () => { 72 + // Business hours: Every 30 minutes during work hours on weekdays 73 + expect(describeCron("*/30 9-17 * * 1-5")).toBe( 74 + "Every 30 minutes, between 9 AM and 5 PM, on weekdays", 75 + ); 76 + 77 + // Backup: Daily at 3:30 AM 78 + expect(describeCron("30 3 * * *")).toBe("At 3:30 AM"); 79 + 80 + // Weekly report: Every Monday at 9 AM 81 + expect(describeCron("0 9 * * 1")).toBe("At 9:00 AM, on Monday"); 82 + 83 + // Quarterly: First day of every 3 months at midnight 84 + expect(describeCron("0 0 1 */3 *")).toBe("At 12:00 AM, on day 1 of the month, every 3 months"); 85 + 86 + // Every 15 minutes during specific hours 87 + expect(describeCron("*/15 8,9,10 * * *")).toBe("Every 15 minutes, between 8 AM and 10 AM"); 88 + 89 + // Multiple specific times 90 + expect(describeCron("0 6,12,18 * * *")).toBe("At 6:00 AM, 12:00 PM, 6:00 PM"); 91 + 92 + // Weekend mornings 93 + expect(describeCron("0 10 * * 0,6")).toBe("At 10:00 AM, on weekends"); 94 + 95 + // Last week of month (approximation with days 25-31) 96 + expect(describeCron("0 0 25-31 * *")).toBe("At 12:00 AM, on days 25-31 of the month"); 97 + 98 + // Every 2 hours during night 99 + expect(describeCron("0 0-6/2 * * *")).toBe("At 12:00 AM, 2:00 AM, 4:00 AM, 6:00 AM"); 100 + 101 + // Specific days in specific months 102 + expect(describeCron("0 12 15 1,7 *")).toBe( 103 + "At 12:00 PM, on day 15 of the month, in January, July", 104 + ); 105 + 106 + // Every 5 minutes during lunch hour 107 + expect(describeCron("*/5 12 * * 1-5")).toBe("Every 5 minutes, at 12 PM, on weekdays"); 108 + 109 + // Multiple days and times 110 + expect(describeCron("30 8,17 * * 1,3,5")).toBe( 111 + "At 8:30 AM, 5:30 PM, on Monday, Wednesday, Friday", 112 + ); 113 + 114 + // Early morning every other day (approximation) 115 + expect(describeCron("0 5 */2 * *")).toBe("At 5:00 AM, every 2 days"); 116 + 117 + // Bi-weekly (every 14 days, approximation) 118 + expect(describeCron("0 9 */14 * *")).toBe("At 9:00 AM, on days 1, 15, 29 of the month"); 119 + 120 + // Summer months, weekdays, business hours 121 + expect(describeCron("0 9-17 * 6-8 1-5")).toBe( 122 + "At minute 0, between 9 AM and 5 PM, on weekdays, in June-August", 123 + ); 124 + 125 + // Every 10 minutes during peak hours 126 + expect(describeCron("*/10 8-11,14-17 * * 1-5")).toBe( 127 + "Every 10 minutes, at 8 AM, 9 AM, 10 AM, 11 AM, 2 PM, and 3 more, on weekdays", 128 + ); 129 + 130 + // First Monday of month at 9 AM (approximation with days 1-7) 131 + expect(describeCron("0 9 1-7 * 1")).toBe("At 9:00 AM, on days 1-7 of the month or on Monday"); 132 + 133 + // Every 6 hours starting at midnight 134 + expect(describeCron("0 */6 * * *")).toBe("At 12:00 AM, 6:00 AM, 12:00 PM, 6:00 PM"); 135 + 136 + // Specific minutes in an hour 137 + expect(describeCron("5,15,25,35,45,55 * * * *")).toBe("At minutes 5, 15, 25, and 3 more"); 138 + }); 139 + 140 + it("should describe edge cases", () => { 141 + // All specific values 142 + expect(describeCron("5 14 15 3 2")).toBe( 143 + "At 2:05 PM, on day 15 of the month or on Tuesday, in March", 144 + ); 145 + 146 + // Single values everywhere 147 + expect(describeCron("0 0 1 1 1")).toBe( 148 + "At 12:00 AM, on day 1 of the month or on Monday, in January", 149 + ); 150 + 151 + // Maximum ranges 152 + expect(describeCron("0-59 0-23 1-31 1-12 0-6")).toBe("Every minute"); 153 + 154 + // Step from non-zero 155 + expect(describeCron("5-55/10 * * * *")).toBe("At minutes 5, 15, 25, and 3 more"); 156 + }); 157 + 158 + it("should handle very long/complex expressions", () => { 159 + // Many specific minutes (detected as step pattern) 160 + expect(describeCron("0,5,10,15,20,25,30,35,40,45,50,55 * * * *")).toBe("Every 5 minutes"); 161 + 162 + // Many specific hours (more than 5, so truncated) 163 + expect(describeCron("0 1,2,3,4,5,6,7,8,9,10,11,12 * * *")).toBe( 164 + "At minute 0, between 1 AM and 12 PM", 165 + ); 166 + 167 + // Many specific days 168 + expect(describeCron("0 0 1,5,10,15,20,25,30 * *")).toBe( 169 + "At 12:00 AM, on days 1, 5, 10, and 4 more of the month", 170 + ); 171 + 172 + // Many specific months (detected as range) 173 + expect(describeCron("0 0 1 1,2,3,4,5,6,7,8,9,10 *")).toBe( 174 + "At 12:00 AM, on day 1 of the month, in January-October", 175 + ); 176 + 177 + // Complex combination with many values 178 + expect(describeCron("0,15,30,45 8,9,10,11,12,13,14,15,16,17 * * 1,2,3,4,5")).toBe( 179 + "Every 15 minutes, between 8 AM and 5 PM, on weekdays", 180 + ); 181 + 182 + // Very specific: multiple minutes, hours, days, months, and weekdays 183 + expect(describeCron("5,10,15,20 6,12,18 1,15 1,4,7,10 1,3,5")).toBe( 184 + "At minutes 5, 10, 15, 20, at 6 AM, 12 PM, 6 PM, on days 1, 15 of the month or on Monday, Wednesday, Friday, in January, April, July, October", 185 + ); 186 + 187 + // Long range with specific weekdays 188 + expect(describeCron("0 9-17 1-15 * 1,2,3,4,5")).toBe( 189 + "At minute 0, between 9 AM and 5 PM, on days 1-15 of the month or on weekdays", 190 + ); 191 + 192 + // Multiple ranges and lists combined 193 + expect(describeCron("0,30 6-8,14-16,20-22 * * *")).toBe( 194 + "At minutes 0, 30, at 6 AM, 7 AM, 8 AM, 2 PM, 3 PM, and 4 more", 195 + ); 196 + 197 + // Extreme: many specific values across all fields 198 + expect(describeCron("1,2,3,4,5 7,8,9,10,11 2,4,6,8,10 2,5,8,11 0,6")).toBe( 199 + "At minutes 1, 2, 3, and 2 more, at 7 AM, 8 AM, 9 AM, 10 AM, 11 AM, on days 2, 4, 6, and 2 more of the month or on weekends, in February, May, August, November", 200 + ); 201 + }); 202 + });