figranium 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,316 +1,325 @@
1
- /**
2
- * Zero-dependency cron expression parser, builder, and scheduler utility.
3
- * Supports standard 5-field cron: minute hour day-of-month month day-of-week
4
- * Plus presets: @yearly, @monthly, @weekly, @daily, @hourly
5
- */
6
-
7
- const PRESETS = {
8
- '@yearly': '0 0 1 1 *',
9
- '@annually': '0 0 1 1 *',
10
- '@monthly': '0 0 1 * *',
11
- '@weekly': '0 0 * * 0',
12
- '@daily': '0 0 * * *',
13
- '@midnight': '0 0 * * *',
14
- '@hourly': '0 * * * *',
15
- };
16
-
17
- const FIELD_RANGES = [
18
- { name: 'minute', min: 0, max: 59 },
19
- { name: 'hour', min: 0, max: 23 },
20
- { name: 'dayOfMonth', min: 1, max: 31 },
21
- { name: 'month', min: 1, max: 12 },
22
- { name: 'dayOfWeek', min: 0, max: 7 }, // 0 and 7 both represent Sunday
23
- ];
24
-
25
- /**
26
- * Parse a single cron field into a Set of valid integer values.
27
- */
28
- function parseField(field, min, max) {
29
- const values = new Set();
30
-
31
- for (const part of field.split(',')) {
32
- const stepMatch = part.match(/^(.+)\/(\d+)$/);
33
- let base = stepMatch ? stepMatch[1] : part;
34
- const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
35
-
36
- if (step < 1) throw new Error(`Invalid step: ${part}`);
37
-
38
- let rangeStart = min;
39
- let rangeEnd = max;
40
-
41
- if (base === '*') {
42
- // full range
43
- } else {
44
- const rangeMatch = base.match(/^(\d+)-(\d+)$/);
45
- if (rangeMatch) {
46
- rangeStart = parseInt(rangeMatch[1], 10);
47
- rangeEnd = parseInt(rangeMatch[2], 10);
48
- } else {
49
- const val = parseInt(base, 10);
50
- if (isNaN(val)) throw new Error(`Invalid cron field value: ${base}`);
51
- if (!stepMatch) {
52
- // single value, clamp dayOfWeek 7 → 0
53
- values.add(val === 7 && max === 7 ? 0 : val);
54
- continue;
55
- }
56
- rangeStart = val;
57
- }
58
- }
59
-
60
- for (let i = rangeStart; i <= rangeEnd; i += step) {
61
- values.add(i === 7 && max === 7 ? 0 : i);
62
- }
63
- }
64
-
65
- return values;
66
- }
67
-
68
- /**
69
- * Parse a full cron expression into an object with Sets for each field.
70
- * @param {string} expression
71
- * @returns {{ minute: Set<number>, hour: Set<number>, dayOfMonth: Set<number>, month: Set<number>, dayOfWeek: Set<number> }}
72
- */
73
- function parseCron(expression) {
74
- if (!expression || typeof expression !== 'string') {
75
- throw new Error('Invalid cron expression');
76
- }
77
-
78
- const expr = expression.trim().toLowerCase();
79
- const resolved = PRESETS[expr] || expr;
80
- const parts = resolved.split(/\s+/);
81
-
82
- if (parts.length !== 5) {
83
- throw new Error(`Cron expression must have 5 fields, got ${parts.length}: "${expression}"`);
84
- }
85
-
86
- const result = {};
87
- for (let i = 0; i < 5; i++) {
88
- const { name, min, max } = FIELD_RANGES[i];
89
- result[name] = parseField(parts[i], min, max);
90
- }
91
- return result;
92
- }
93
-
94
- /**
95
- * Check if a cron expression is valid.
96
- * @param {string} expression
97
- * @returns {boolean}
98
- */
99
- function isValidCron(expression) {
100
- try {
101
- parseCron(expression);
102
- return true;
103
- } catch {
104
- return false;
105
- }
106
- }
107
-
108
- /**
109
- * Get the next run time after `from` that matches the cron expression.
110
- * @param {string} expression
111
- * @param {Date} [from]
112
- * @returns {Date}
113
- */
114
- function getNextRun(expression, from) {
115
- const fields = parseCron(expression);
116
- const d = from ? new Date(from) : new Date();
117
- // Start from the next minute
118
- d.setSeconds(0, 0);
119
- d.setMinutes(d.getMinutes() + 1);
120
-
121
- // Safety: limit iterations to avoid infinite loop (covers ~4 years)
122
- const maxIterations = 366 * 24 * 60;
123
- for (let i = 0; i < maxIterations; i++) {
124
- if (
125
- fields.month.has(d.getMonth() + 1) &&
126
- fields.dayOfMonth.has(d.getDate()) &&
127
- fields.dayOfWeek.has(d.getDay()) &&
128
- fields.hour.has(d.getHours()) &&
129
- fields.minute.has(d.getMinutes())
130
- ) {
131
- return d;
132
- }
133
- d.setMinutes(d.getMinutes() + 1);
134
- }
135
-
136
- throw new Error(`Could not find next run for expression: ${expression}`);
137
- }
138
-
139
- /**
140
- * Convert a no-code schedule config object into a cron expression.
141
- * @param {object} config
142
- * @param {string} config.frequency - 'interval' | 'hourly' | 'daily' | 'weekly' | 'monthly'
143
- * @param {number} [config.intervalMinutes] - for 'interval' frequency
144
- * @param {number} [config.hour] - hour (0-23)
145
- * @param {number} [config.minute] - minute (0-59)
146
- * @param {number[]} [config.daysOfWeek] - for 'weekly' (0=Sun..6=Sat)
147
- * @param {number} [config.dayOfMonth] - for 'monthly' (1-31)
148
- * @returns {string}
149
- */
150
- function scheduleToCron(config) {
151
- if (!config || !config.frequency) {
152
- throw new Error('Schedule config must include a frequency');
153
- }
154
-
155
- const min = config.minute ?? 0;
156
- const hr = config.hour ?? 0;
157
-
158
- switch (config.frequency) {
159
- case 'interval': {
160
- const interval = config.intervalMinutes || 5;
161
- if (interval <= 0 || interval > 1440) throw new Error('Interval must be 1-1440 minutes');
162
-
163
- if (interval <= 60) {
164
- if (60 % interval === 0) {
165
- return `*/${interval} * * * *`;
166
- }
167
- const minutes = [];
168
- for (let m = 0; m < 60; m += interval) {
169
- minutes.push(m);
170
- }
171
- return `${minutes.join(',')} * * * *`;
172
- } else {
173
- // For intervals > 60m, use hours/minutes
174
- const hrs = Math.floor(interval / 60);
175
- const mins = interval % 60;
176
- if (hrs < 24 && 24 % hrs === 0 && mins === 0) {
177
- return `0 */${hrs} * * *`;
178
- }
179
- // Fallback to daily if too complex, or just return hourly with 0 min
180
- return `0 */${Math.max(1, hrs)} * * *`;
181
- }
182
- }
183
- case 'hourly':
184
- return `${min} * * * *`;
185
- case 'daily':
186
- return `${min} ${hr} * * *`;
187
- case 'weekly': {
188
- const days = Array.isArray(config.daysOfWeek) && config.daysOfWeek.length > 0
189
- ? config.daysOfWeek.sort().join(',')
190
- : '*';
191
- return `${min} ${hr} * * ${days}`;
192
- }
193
- case 'monthly': {
194
- const dom = config.dayOfMonth || 1;
195
- return `${min} ${hr} ${dom} * *`;
196
- }
197
- default:
198
- throw new Error(`Unknown frequency: ${config.frequency}`);
199
- }
200
- }
201
-
202
- /**
203
- * Produce a human-readable description of a cron expression.
204
- * @param {string} expression
205
- * @returns {string}
206
- */
207
- function describeCron(expression) {
208
- if (!expression || typeof expression !== 'string') return '';
209
-
210
- const expr = expression.trim().toLowerCase();
211
- if (PRESETS[expr]) {
212
- const labels = {
213
- '@yearly': 'Every year on January 1st at midnight',
214
- '@annually': 'Every year on January 1st at midnight',
215
- '@monthly': 'First day of every month at midnight',
216
- '@weekly': 'Every Sunday at midnight',
217
- '@daily': 'Every day at midnight',
218
- '@midnight': 'Every day at midnight',
219
- '@hourly': 'Every hour',
220
- };
221
- return labels[expr] || expr;
222
- }
223
-
224
- try {
225
- const parts = expr.split(/\s+/);
226
- if (parts.length !== 5) return expression;
227
-
228
- const [minPart, hrPart, domPart, monPart, dowPart] = parts;
229
-
230
- const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
231
- const monthNames = ['', 'January', 'February', 'March', 'April', 'May', 'June',
232
- 'July', 'August', 'September', 'October', 'November', 'December'];
233
-
234
- const formatTime = (h, m) => {
235
- const hour = parseInt(h, 10);
236
- const minute = parseInt(m, 10);
237
- if (isNaN(hour) || isNaN(minute)) return '';
238
- const ampm = hour >= 12 ? 'PM' : 'AM';
239
- const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
240
- return `${h12}:${String(minute).padStart(2, '0')} ${ampm}`;
241
- };
242
-
243
- // Every N minutes
244
- const stepMatch = minPart.match(/^\*\/(\d+)$/);
245
- if (stepMatch && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
246
- const n = parseInt(stepMatch[1], 10);
247
- if (n === 1) return 'Every minute';
248
- return `Every ${n} minutes`;
249
- }
250
-
251
- // Every minute
252
- if (minPart === '*' && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
253
- return 'Every minute';
254
- }
255
-
256
- // Comma separated minutes (common for non-divisible intervals)
257
- if (hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*' && minPart.includes(',')) {
258
- const mins = minPart.split(',');
259
- if (mins.every(m => /^\d+$/.test(m))) {
260
- const diffs = [];
261
- for (let i = 1; i < mins.length; i++) diffs.push(parseInt(mins[i]) - parseInt(mins[i-1]));
262
- const uniqueDiffs = new Set(diffs);
263
- if (uniqueDiffs.size === 1) {
264
- return `Every ${uniqueDiffs.values().next().value} minutes`;
265
- }
266
- }
267
- }
268
-
269
- // Specific minute, every hour
270
- if (/^\d+$/.test(minPart) && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
271
- const m = parseInt(minPart, 10);
272
- return `Every hour at :${String(m).padStart(2, '0')}`;
273
- }
274
-
275
- // Daily at specific time
276
- if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && domPart === '*' && monPart === '*' && dowPart === '*') {
277
- return `Every day at ${formatTime(hrPart, minPart)}`;
278
- }
279
-
280
- // Weekly
281
- if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && domPart === '*' && monPart === '*' && dowPart !== '*') {
282
- const dows = dowPart.split(',').map(d => {
283
- const n = parseInt(d, 10);
284
- return dayNames[n === 7 ? 0 : n] || d;
285
- });
286
- if (dows.length === 5 && !dows.includes('Saturday') && !dows.includes('Sunday')) {
287
- return `Every weekday at ${formatTime(hrPart, minPart)}`;
288
- }
289
- if (dows.length === 2 && dows.includes('Saturday') && dows.includes('Sunday')) {
290
- return `Every weekend at ${formatTime(hrPart, minPart)}`;
291
- }
292
- return `Every ${dows.join(', ')} at ${formatTime(hrPart, minPart)}`;
293
- }
294
-
295
- // Monthly
296
- if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && /^\d+$/.test(domPart) && monPart === '*' && dowPart === '*') {
297
- const dom = parseInt(domPart, 10);
298
- const suffix = dom === 1 ? 'st' : dom === 2 ? 'nd' : dom === 3 ? 'rd' : 'th';
299
- return `Monthly on the ${dom}${suffix} at ${formatTime(hrPart, minPart)}`;
300
- }
301
-
302
- // Fallback: return a structured description
303
- return expression;
304
- } catch {
305
- return expression;
306
- }
307
- }
308
-
309
- module.exports = {
310
- parseCron,
311
- isValidCron,
312
- getNextRun,
313
- scheduleToCron,
314
- describeCron,
315
- PRESETS,
316
- };
1
+ /**
2
+ * Zero-dependency cron expression parser, builder, and scheduler utility.
3
+ * Supports standard 5-field cron: minute hour day-of-month month day-of-week
4
+ * Plus presets: @yearly, @monthly, @weekly, @daily, @hourly
5
+ */
6
+
7
+ const PRESETS = {
8
+ '@yearly': '0 0 1 1 *',
9
+ '@annually': '0 0 1 1 *',
10
+ '@monthly': '0 0 1 * *',
11
+ '@weekly': '0 0 * * 0',
12
+ '@daily': '0 0 * * *',
13
+ '@midnight': '0 0 * * *',
14
+ '@hourly': '0 * * * *',
15
+ };
16
+
17
+ const FIELD_RANGES = [
18
+ { name: 'minute', min: 0, max: 59 },
19
+ { name: 'hour', min: 0, max: 23 },
20
+ { name: 'dayOfMonth', min: 1, max: 31 },
21
+ { name: 'month', min: 1, max: 12 },
22
+ { name: 'dayOfWeek', min: 0, max: 7 }, // 0 and 7 both represent Sunday
23
+ ];
24
+
25
+ /**
26
+ * Parse a single cron field into a Set of valid integer values.
27
+ */
28
+ function parseField(field, min, max) {
29
+ const values = new Set();
30
+
31
+ for (const part of field.split(',')) {
32
+ const stepMatch = part.match(/^(.+)\/(\d+)$/);
33
+ let base = stepMatch ? stepMatch[1] : part;
34
+ const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
35
+
36
+ if (step < 1) throw new Error(`Invalid step: ${part}`);
37
+
38
+ let rangeStart = min;
39
+ let rangeEnd = max;
40
+
41
+ if (base === '*') {
42
+ // full range
43
+ } else {
44
+ const rangeMatch = base.match(/^(\d+)-(\d+)$/);
45
+ if (rangeMatch) {
46
+ rangeStart = parseInt(rangeMatch[1], 10);
47
+ rangeEnd = parseInt(rangeMatch[2], 10);
48
+ } else {
49
+ const val = parseInt(base, 10);
50
+ if (isNaN(val)) throw new Error(`Invalid cron field value: ${base}`);
51
+ if (!stepMatch) {
52
+ // single value, clamp dayOfWeek 7 → 0
53
+ values.add(val === 7 && max === 7 ? 0 : val);
54
+ continue;
55
+ }
56
+ rangeStart = val;
57
+ }
58
+ }
59
+
60
+ for (let i = rangeStart; i <= rangeEnd; i += step) {
61
+ values.add(i === 7 && max === 7 ? 0 : i);
62
+ }
63
+ }
64
+
65
+ return values;
66
+ }
67
+
68
+ /**
69
+ * Parse a full cron expression into an object with Sets for each field.
70
+ * @param {string} expression
71
+ * @returns {{ minute: Set<number>, hour: Set<number>, dayOfMonth: Set<number>, month: Set<number>, dayOfWeek: Set<number> }}
72
+ */
73
+ function parseCron(expression) {
74
+ if (!expression || typeof expression !== 'string') {
75
+ throw new Error('Invalid cron expression');
76
+ }
77
+
78
+ const expr = expression.trim().toLowerCase();
79
+ const resolved = PRESETS[expr] || expr;
80
+ const parts = resolved.split(/\s+/);
81
+
82
+ if (parts.length !== 5) {
83
+ throw new Error(`Cron expression must have 5 fields, got ${parts.length}: "${expression}"`);
84
+ }
85
+
86
+ const result = {};
87
+ for (let i = 0; i < 5; i++) {
88
+ const { name, min, max } = FIELD_RANGES[i];
89
+ result[name] = parseField(parts[i], min, max);
90
+ }
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Check if a cron expression is valid.
96
+ * @param {string} expression
97
+ * @returns {boolean}
98
+ */
99
+ function isValidCron(expression) {
100
+ try {
101
+ parseCron(expression);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get the next run time after `from` that matches the cron expression.
110
+ * @param {string} expression
111
+ * @param {Date} [from]
112
+ * @returns {Date}
113
+ */
114
+ function getNextRun(expression, from) {
115
+ const fields = parseCron(expression);
116
+ const d = from ? new Date(from) : new Date();
117
+ // Start from the next minute
118
+ d.setSeconds(0, 0);
119
+ d.setMinutes(d.getMinutes() + 1);
120
+
121
+ // ⚡ Bolt: Optimized skipping logic reduces iterations from ~500,000 to < 100 for sparse crons
122
+ // Instead of incrementing by minute, we jump to the next potential valid month, day, or hour.
123
+ for (let i = 0; i < 10000; i++) {
124
+ if (!fields.month.has(d.getMonth() + 1)) {
125
+ d.setMonth(d.getMonth() + 1, 1);
126
+ d.setHours(0, 0, 0, 0);
127
+ continue;
128
+ }
129
+ if (!fields.dayOfMonth.has(d.getDate()) || !fields.dayOfWeek.has(d.getDay())) {
130
+ d.setDate(d.getDate() + 1);
131
+ d.setHours(0, 0, 0, 0);
132
+ continue;
133
+ }
134
+ if (!fields.hour.has(d.getHours())) {
135
+ d.setHours(d.getHours() + 1, 0, 0, 0);
136
+ continue;
137
+ }
138
+ if (!fields.minute.has(d.getMinutes())) {
139
+ d.setMinutes(d.getMinutes() + 1);
140
+ continue;
141
+ }
142
+ return d;
143
+ }
144
+
145
+ throw new Error(`Could not find next run for expression: ${expression}`);
146
+ }
147
+
148
+ /**
149
+ * Convert a no-code schedule config object into a cron expression.
150
+ * @param {object} config
151
+ * @param {string} config.frequency - 'interval' | 'hourly' | 'daily' | 'weekly' | 'monthly'
152
+ * @param {number} [config.intervalMinutes] - for 'interval' frequency
153
+ * @param {number} [config.hour] - hour (0-23)
154
+ * @param {number} [config.minute] - minute (0-59)
155
+ * @param {number[]} [config.daysOfWeek] - for 'weekly' (0=Sun..6=Sat)
156
+ * @param {number} [config.dayOfMonth] - for 'monthly' (1-31)
157
+ * @returns {string}
158
+ */
159
+ function scheduleToCron(config) {
160
+ if (!config || !config.frequency) {
161
+ throw new Error('Schedule config must include a frequency');
162
+ }
163
+
164
+ const min = config.minute ?? 0;
165
+ const hr = config.hour ?? 0;
166
+
167
+ switch (config.frequency) {
168
+ case 'interval': {
169
+ const interval = config.intervalMinutes || 5;
170
+ if (interval <= 0 || interval > 1440) throw new Error('Interval must be 1-1440 minutes');
171
+
172
+ if (interval <= 60) {
173
+ if (60 % interval === 0) {
174
+ return `*/${interval} * * * *`;
175
+ }
176
+ const minutes = [];
177
+ for (let m = 0; m < 60; m += interval) {
178
+ minutes.push(m);
179
+ }
180
+ return `${minutes.join(',')} * * * *`;
181
+ } else {
182
+ // For intervals > 60m, use hours/minutes
183
+ const hrs = Math.floor(interval / 60);
184
+ const mins = interval % 60;
185
+ if (hrs < 24 && 24 % hrs === 0 && mins === 0) {
186
+ return `0 */${hrs} * * *`;
187
+ }
188
+ // Fallback to daily if too complex, or just return hourly with 0 min
189
+ return `0 */${Math.max(1, hrs)} * * *`;
190
+ }
191
+ }
192
+ case 'hourly':
193
+ return `${min} * * * *`;
194
+ case 'daily':
195
+ return `${min} ${hr} * * *`;
196
+ case 'weekly': {
197
+ const days = Array.isArray(config.daysOfWeek) && config.daysOfWeek.length > 0
198
+ ? config.daysOfWeek.sort().join(',')
199
+ : '*';
200
+ return `${min} ${hr} * * ${days}`;
201
+ }
202
+ case 'monthly': {
203
+ const dom = config.dayOfMonth || 1;
204
+ return `${min} ${hr} ${dom} * *`;
205
+ }
206
+ default:
207
+ throw new Error(`Unknown frequency: ${config.frequency}`);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Produce a human-readable description of a cron expression.
213
+ * @param {string} expression
214
+ * @returns {string}
215
+ */
216
+ function describeCron(expression) {
217
+ if (!expression || typeof expression !== 'string') return '';
218
+
219
+ const expr = expression.trim().toLowerCase();
220
+ if (PRESETS[expr]) {
221
+ const labels = {
222
+ '@yearly': 'Every year on January 1st at midnight',
223
+ '@annually': 'Every year on January 1st at midnight',
224
+ '@monthly': 'First day of every month at midnight',
225
+ '@weekly': 'Every Sunday at midnight',
226
+ '@daily': 'Every day at midnight',
227
+ '@midnight': 'Every day at midnight',
228
+ '@hourly': 'Every hour',
229
+ };
230
+ return labels[expr] || expr;
231
+ }
232
+
233
+ try {
234
+ const parts = expr.split(/\s+/);
235
+ if (parts.length !== 5) return expression;
236
+
237
+ const [minPart, hrPart, domPart, monPart, dowPart] = parts;
238
+
239
+ const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
240
+ const monthNames = ['', 'January', 'February', 'March', 'April', 'May', 'June',
241
+ 'July', 'August', 'September', 'October', 'November', 'December'];
242
+
243
+ const formatTime = (h, m) => {
244
+ const hour = parseInt(h, 10);
245
+ const minute = parseInt(m, 10);
246
+ if (isNaN(hour) || isNaN(minute)) return '';
247
+ const ampm = hour >= 12 ? 'PM' : 'AM';
248
+ const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
249
+ return `${h12}:${String(minute).padStart(2, '0')} ${ampm}`;
250
+ };
251
+
252
+ // Every N minutes
253
+ const stepMatch = minPart.match(/^\*\/(\d+)$/);
254
+ if (stepMatch && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
255
+ const n = parseInt(stepMatch[1], 10);
256
+ if (n === 1) return 'Every minute';
257
+ return `Every ${n} minutes`;
258
+ }
259
+
260
+ // Every minute
261
+ if (minPart === '*' && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
262
+ return 'Every minute';
263
+ }
264
+
265
+ // Comma separated minutes (common for non-divisible intervals)
266
+ if (hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*' && minPart.includes(',')) {
267
+ const mins = minPart.split(',');
268
+ if (mins.every(m => /^\d+$/.test(m))) {
269
+ const diffs = [];
270
+ for (let i = 1; i < mins.length; i++) diffs.push(parseInt(mins[i]) - parseInt(mins[i-1]));
271
+ const uniqueDiffs = new Set(diffs);
272
+ if (uniqueDiffs.size === 1) {
273
+ return `Every ${uniqueDiffs.values().next().value} minutes`;
274
+ }
275
+ }
276
+ }
277
+
278
+ // Specific minute, every hour
279
+ if (/^\d+$/.test(minPart) && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
280
+ const m = parseInt(minPart, 10);
281
+ return `Every hour at :${String(m).padStart(2, '0')}`;
282
+ }
283
+
284
+ // Daily at specific time
285
+ if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && domPart === '*' && monPart === '*' && dowPart === '*') {
286
+ return `Every day at ${formatTime(hrPart, minPart)}`;
287
+ }
288
+
289
+ // Weekly
290
+ if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && domPart === '*' && monPart === '*' && dowPart !== '*') {
291
+ const dows = dowPart.split(',').map(d => {
292
+ const n = parseInt(d, 10);
293
+ return dayNames[n === 7 ? 0 : n] || d;
294
+ });
295
+ if (dows.length === 5 && !dows.includes('Saturday') && !dows.includes('Sunday')) {
296
+ return `Every weekday at ${formatTime(hrPart, minPart)}`;
297
+ }
298
+ if (dows.length === 2 && dows.includes('Saturday') && dows.includes('Sunday')) {
299
+ return `Every weekend at ${formatTime(hrPart, minPart)}`;
300
+ }
301
+ return `Every ${dows.join(', ')} at ${formatTime(hrPart, minPart)}`;
302
+ }
303
+
304
+ // Monthly
305
+ if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && /^\d+$/.test(domPart) && monPart === '*' && dowPart === '*') {
306
+ const dom = parseInt(domPart, 10);
307
+ const suffix = dom === 1 ? 'st' : dom === 2 ? 'nd' : dom === 3 ? 'rd' : 'th';
308
+ return `Monthly on the ${dom}${suffix} at ${formatTime(hrPart, minPart)}`;
309
+ }
310
+
311
+ // Fallback: return a structured description
312
+ return expression;
313
+ } catch {
314
+ return expression;
315
+ }
316
+ }
317
+
318
+ module.exports = {
319
+ parseCron,
320
+ isValidCron,
321
+ getNextRun,
322
+ scheduleToCron,
323
+ describeCron,
324
+ PRESETS,
325
+ };