• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22531306485

28 Feb 2026 11:25PM UTC coverage: 90.135% (-0.4%) from 90.52%
22531306485

Pull #153

github

web-flow
Merge 59707c805 into d66e0f9e2
Pull Request #153: feat: add /remind command with natural language time parsing

4418 of 5187 branches covered (85.17%)

Branch coverage included in aggregate %.

290 of 337 new or added lines in 7 files covered. (86.05%)

2 existing lines in 1 file now uncovered.

7579 of 8123 relevant lines covered (93.3%)

49.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.9
/src/utils/cronParser.js
1
/**
2
 * Cron expression parsing utilities.
3
 * Extracted from scheduler.js to break the circular dependency between
4
 * scheduler.js and reminderHandler.js.
5
 *
6
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/137
7
 */
8

9
/**
10
 * Parse a 5-field cron expression into its component arrays.
11
 * Supports: numbers, wildcards (*), and single values.
12
 *
13
 * @param {string} cronExpr - 5-field cron expression (minute hour day month weekday)
14
 * @returns {{ minute: number[], hour: number[], day: number[], month: number[], weekday: number[] }}
15
 */
16
export function parseCron(cronExpr) {
17
  const fields = cronExpr.trim().split(/\s+/);
28✔
18
  if (fields.length !== 5) {
28✔
19
    throw new Error(`Invalid cron expression: expected 5 fields, got ${fields.length}`);
4✔
20
  }
21

22
  const ranges = [
24✔
23
    { min: 0, max: 59 }, // minute
24
    { min: 0, max: 23 }, // hour
25
    { min: 1, max: 31 }, // day of month
26
    { min: 1, max: 12 }, // month
27
    { min: 0, max: 6 }, // day of week (0 = Sunday)
28
  ];
29

30
  const names = ['minute', 'hour', 'day', 'month', 'weekday'];
24✔
31
  const result = {};
24✔
32

33
  for (let i = 0; i < 5; i++) {
24✔
34
    const field = fields[i];
98✔
35
    const { min, max } = ranges[i];
98✔
36

37
    if (field === '*') {
98✔
38
      const arr = [];
58✔
39
      for (let v = min; v <= max; v++) arr.push(v);
1,158✔
40
      result[names[i]] = arr;
58✔
41
    } else if (field.includes(',')) {
40✔
42
      result[names[i]] = field.split(',').map((v) => {
3✔
43
        const n = Number.parseInt(v, 10);
6✔
44
        if (Number.isNaN(n) || n < min || n > max) {
6✔
45
          throw new Error(`Invalid cron value "${v}" for ${names[i]}`);
1✔
46
        }
47
        return n;
5✔
48
      });
49
    } else if (field.includes('-')) {
37✔
50
      const [start, end] = field.split('-').map((v) => Number.parseInt(v, 10));
8✔
51
      if (Number.isNaN(start) || Number.isNaN(end) || start < min || end > max || start > end) {
4✔
52
        throw new Error(`Invalid cron range "${field}" for ${names[i]}`);
2✔
53
      }
54
      const arr = [];
2✔
55
      for (let v = start; v <= end; v++) arr.push(v);
14✔
56
      result[names[i]] = arr;
2✔
57
    } else if (field.includes('/')) {
33✔
58
      const [base, step] = field.split('/');
4✔
59
      const stepNum = Number.parseInt(step, 10);
4✔
60
      const startNum = base === '*' ? min : Number.parseInt(base, 10);
4✔
61
      if (Number.isNaN(stepNum) || stepNum <= 0 || Number.isNaN(startNum)) {
4✔
62
        throw new Error(`Invalid cron step "${field}" for ${names[i]}`);
2✔
63
      }
64
      const arr = [];
2✔
65
      for (let v = startNum; v <= max; v += stepNum) arr.push(v);
7✔
66
      result[names[i]] = arr;
2✔
67
    } else {
68
      const n = Number.parseInt(field, 10);
29✔
69
      if (Number.isNaN(n) || n < min || n > max) {
29✔
70
        throw new Error(`Invalid cron value "${field}" for ${names[i]}`);
2✔
71
      }
72
      result[names[i]] = [n];
27✔
73
    }
74
  }
75

76
  return result;
91✔
77
}
78

79
/**
80
 * Compute the next run time from a cron expression after a given date.
81
 *
82
 * @param {string} cronExpr - 5-field cron expression
83
 * @param {Date} fromDate - Starting date to search from
84
 * @returns {Date} Next matching date/time
85
 */
86
export function getNextCronRun(cronExpr, fromDate) {
87
  const cron = parseCron(cronExpr);
8✔
88

89
  // Start from the next minute after fromDate
90
  const d = new Date(fromDate.getTime());
8✔
91
  d.setSeconds(0, 0);
8✔
92
  d.setMinutes(d.getMinutes() + 1);
8✔
93

94
  // Safety: limit search to 2 years to prevent infinite loops
95
  const limit = new Date(fromDate.getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
8✔
96

97
  while (d < limit) {
8✔
98
    if (
48,009✔
99
      cron.month.includes(d.getMonth() + 1) &&
104,865✔
100
      cron.day.includes(d.getDate()) &&
101
      cron.weekday.includes(d.getDay()) &&
102
      cron.hour.includes(d.getHours()) &&
103
      cron.minute.includes(d.getMinutes())
104
    ) {
105
      return d;
7✔
106
    }
107

108
    // Advance by 1 minute
109
    d.setMinutes(d.getMinutes() + 1);
48,002✔
110
  }
111

NEW
112
  throw new Error(`No matching cron time found within 2 years for: ${cronExpr}`);
×
113
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc