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

VolvoxLLC / volvox-bot / 25277590437

03 May 2026 11:12AM UTC coverage: 90.19% (+0.03%) from 90.158%
25277590437

push

github

BillChirico
docs: restore wiki home links

10082 of 11816 branches covered (85.32%)

Branch coverage included in aggregate %.

15890 of 16981 relevant lines covered (93.58%)

169.31 hits per line

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

94.08
/src/utils/timeParser.js
1
/**
2
 * Natural Language Time Parser
3
 * Zero-dependency parser for relative time expressions.
4
 *
5
 * Supported formats:
6
 * - "in 5 minutes", "in 2 hours", "in 1 day", "in 3 weeks"
7
 * - "tomorrow", "tomorrow at 3pm"
8
 * - "next monday", "next friday at 9am"
9
 * - Shorthand: "5m", "2h", "1d", "30s"
10
 *
11
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/137
12
 */
13

14
/** Millisecond multipliers for time units */
15
const UNIT_MS = {
3✔
16
  second: 1_000,
17
  seconds: 1_000,
18
  sec: 1_000,
19
  secs: 1_000,
20
  s: 1_000,
21
  minute: 60_000,
22
  minutes: 60_000,
23
  min: 60_000,
24
  mins: 60_000,
25
  m: 60_000,
26
  hour: 3_600_000,
27
  hours: 3_600_000,
28
  hr: 3_600_000,
29
  hrs: 3_600_000,
30
  h: 3_600_000,
31
  day: 86_400_000,
32
  days: 86_400_000,
33
  d: 86_400_000,
34
  week: 604_800_000,
35
  weeks: 604_800_000,
36
  w: 604_800_000,
37
};
38

39
const TIME_OF_DAY_SOURCE = String.raw`\d{1,2}(?::\d{2})?\s*(?:[ap]m)?`;
3✔
40
const TOMORROW_PATTERN = new RegExp(
3✔
41
  String.raw`^tomorrow(?:\s+at\s+(${TIME_OF_DAY_SOURCE}))?(?:\s|$)`,
42
);
43
const NEXT_DAY_PATTERN = new RegExp(
3✔
44
  String.raw`^next\s+([a-z]{3,9})(?:\s+at\s+(${TIME_OF_DAY_SOURCE}))?(?:\s|$)`,
45
);
46

47
/** Day name → JS getDay() index */
48
const DAY_NAMES = {
3✔
49
  sunday: 0,
50
  sun: 0,
51
  monday: 1,
52
  mon: 1,
53
  tuesday: 2,
54
  tue: 2,
55
  tues: 2,
56
  wednesday: 3,
57
  wed: 3,
58
  thursday: 4,
59
  thu: 4,
60
  thur: 4,
61
  thurs: 4,
62
  friday: 5,
63
  fri: 5,
64
  saturday: 6,
65
  sat: 6,
66
};
67

68
/**
69
 * Parse an "at <time>" suffix into hours/minutes.
70
 * Supports: "3pm", "3:30pm", "15:00", "9am", "9:45 am"
71
 *
72
 * @param {string} timeStr - Time portion (e.g. "3pm", "15:00", "9:30am")
73
 * @returns {{ hours: number, minutes: number } | null}
74
 */
75
function parseTimeOfDay(timeStr) {
76
  if (!timeStr) return null;
12!
77
  const cleaned = timeStr.trim().toLowerCase();
12✔
78

79
  // 12-hour: "3pm", "3:30pm", "3:30 pm", "12am"
80
  const match12 = cleaned.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/);
12✔
81
  if (match12) {
12✔
82
    let hours = Number.parseInt(match12[1], 10);
11✔
83
    const minutes = match12[2] ? Number.parseInt(match12[2], 10) : 0;
11✔
84
    const period = match12[3];
11✔
85

86
    if (hours < 1 || hours > 12 || minutes < 0 || minutes > 59) return null;
11!
87

88
    if (period === 'am' && hours === 12) hours = 0;
11✔
89
    else if (period === 'pm' && hours !== 12) hours += 12;
10✔
90

91
    return { hours, minutes };
11✔
92
  }
93

94
  // 24-hour: "15:00", "09:30"
95
  const match24 = cleaned.match(/^(\d{1,2}):(\d{2})$/);
1✔
96
  if (match24) {
1!
97
    const hours = Number.parseInt(match24[1], 10);
1✔
98
    const minutes = Number.parseInt(match24[2], 10);
1✔
99
    if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
1!
100
    return { hours, minutes };
1✔
101
  }
102

103
  return null;
×
104
}
105

106
/**
107
 * Set the time-of-day on a Date, or default to 9:00 AM if no time specified.
108
 *
109
 * @param {Date} date - Target date (mutated in place)
110
 * @param {{ hours: number, minutes: number } | null} time - Parsed time, or null for 9am default
111
 * @returns {Date} The mutated date
112
 */
113
function applyTimeOfDay(date, time) {
114
  if (time) {
17✔
115
    date.setHours(time.hours, time.minutes, 0, 0);
12✔
116
  } else {
117
    date.setHours(9, 0, 0, 0);
5✔
118
  }
119
  return date;
17✔
120
}
121

122
/**
123
 * Try to parse a shorthand time expression like "5m", "2h", "1d".
124
 * @param {string} trimmed - Lowercased trimmed input
125
 * @param {Date} ref - Reference date
126
 * @returns {{ date: Date, consumed: string } | null}
127
 */
128
function tryParseShorthand(trimmed, ref) {
129
  const match = trimmed.match(/^(\d+)\s*([smhdw])(?:\s|$)/);
44✔
130
  if (!match) return null;
44✔
131
  const value = Number.parseInt(match[1], 10);
10✔
132
  if (value <= 0) return null;
10✔
133
  const ms = value * UNIT_MS[match[2]];
9✔
134
  if (!Number.isFinite(ms)) return null;
9!
135
  return { date: new Date(ref.getTime() + ms), consumed: match[0].trim() };
9✔
136
}
137

138
/**
139
 * Try to parse "in <N> <unit>" expressions like "in 5 minutes".
140
 * @param {string} trimmed - Lowercased trimmed input
141
 * @param {Date} ref - Reference date
142
 * @returns {{ date: Date, consumed: string } | null}
143
 */
144
function tryParseInDuration(trimmed, ref) {
145
  const match = trimmed.match(/^in\s+(\d+)\s+([a-z]+)(?:\s|$)/);
35✔
146
  if (!match) return null;
35✔
147
  const value = Number.parseInt(match[1], 10);
13✔
148
  const ms = UNIT_MS[match[2]];
13✔
149
  if (!ms || value <= 0) return null;
13✔
150
  return { date: new Date(ref.getTime() + value * ms), consumed: match[0].trim() };
11✔
151
}
152

153
/**
154
 * Try to parse "tomorrow" or "tomorrow at <time>".
155
 * @param {string} trimmed - Lowercased trimmed input
156
 * @param {Date} ref - Reference date
157
 * @returns {{ date: Date, consumed: string } | null}
158
 */
159
function tryParseTomorrow(trimmed, ref) {
160
  const match = trimmed.match(TOMORROW_PATTERN);
24✔
161
  if (!match) return null;
24✔
162
  const parsedTime = match[1] ? parseTimeOfDay(match[1]) : null;
10✔
163
  if (match[1] && !parsedTime) return null;
24!
164
  const tomorrow = new Date(ref.getTime());
10✔
165
  tomorrow.setDate(tomorrow.getDate() + 1);
10✔
166
  applyTimeOfDay(tomorrow, parsedTime);
10✔
167
  return { date: tomorrow, consumed: match[0].trim() };
10✔
168
}
169

170
/**
171
 * Try to parse "next <day>" or "next <day> at <time>".
172
 * @param {string} trimmed - Lowercased trimmed input
173
 * @param {Date} ref - Reference date
174
 * @returns {{ date: Date, consumed: string } | null}
175
 */
176
function tryParseNextDay(trimmed, ref) {
177
  const match = trimmed.match(NEXT_DAY_PATTERN);
14✔
178
  if (!match) return null;
14✔
179
  const targetDay = DAY_NAMES[match[1]];
8✔
180
  if (targetDay === undefined) return null;
8✔
181
  const parsedTime = match[2] ? parseTimeOfDay(match[2]) : null;
7✔
182
  if (match[2] && !parsedTime) return null;
14!
183
  const result = new Date(ref.getTime());
7✔
184
  let daysAhead = targetDay - result.getDay();
7✔
185
  if (daysAhead <= 0) daysAhead += 7;
7✔
186
  result.setDate(result.getDate() + daysAhead);
7✔
187
  applyTimeOfDay(result, parsedTime);
7✔
188
  return { date: result, consumed: match[0].trim() };
7✔
189
}
190

191
/**
192
 * Parse a natural language time string into a Date.
193
 *
194
 * @param {string} input - Natural language time expression
195
 * @param {Date} [now] - Reference time (defaults to current time)
196
 * @returns {{ date: Date, consumed: string } | null} Parsed date and matched portion, or null
197
 */
198
export function parseTime(input, now) {
199
  if (!input || typeof input !== 'string') return null;
48✔
200

201
  const trimmed = input.trim().toLowerCase();
44✔
202
  if (!trimmed) return null;
44!
203

204
  const ref = now ? new Date(now.getTime()) : new Date();
44✔
205

206
  return (
48✔
207
    tryParseShorthand(trimmed, ref) ??
128✔
208
    tryParseInDuration(trimmed, ref) ??
209
    tryParseTomorrow(trimmed, ref) ??
210
    tryParseNextDay(trimmed, ref) ??
211
    null
212
  );
213
}
214

215
/**
216
 * Parse a time expression from the beginning of a string and return both
217
 * the parsed date and the remaining message text.
218
 *
219
 * @param {string} input - Full input string (time expression + message)
220
 * @param {Date} [now] - Reference time
221
 * @returns {{ date: Date, message: string } | null}
222
 */
223
export function parseTimeAndMessage(input, now) {
224
  if (!input || typeof input !== 'string') return null;
11✔
225

226
  const trimmed = input.trim();
9✔
227
  const result = parseTime(trimmed, now);
9✔
228
  if (!result) return null;
9✔
229

230
  const message = trimmed.slice(result.consumed.length).trim();
7✔
231
  return { date: result.date, message };
7✔
232
}
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