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

VolvoxLLC / volvox-bot / 22531190565

28 Feb 2026 11:18PM UTC coverage: 90.19% (-0.3%) from 90.52%
22531190565

Pull #153

github

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

4409 of 5171 branches covered (85.26%)

Branch coverage included in aggregate %.

262 of 305 new or added lines in 7 files covered. (85.9%)

1 existing line in 1 file now uncovered.

7552 of 8091 relevant lines covered (93.34%)

49.21 hits per line

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

94.93
/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
/** Day name → JS getDay() index */
40
const DAY_NAMES = {
3✔
41
  sunday: 0,
42
  sun: 0,
43
  monday: 1,
44
  mon: 1,
45
  tuesday: 2,
46
  tue: 2,
47
  tues: 2,
48
  wednesday: 3,
49
  wed: 3,
50
  thursday: 4,
51
  thu: 4,
52
  thur: 4,
53
  thurs: 4,
54
  friday: 5,
55
  fri: 5,
56
  saturday: 6,
57
  sat: 6,
58
};
59

60
/**
61
 * Parse an "at <time>" suffix into hours/minutes.
62
 * Supports: "3pm", "3:30pm", "15:00", "9am", "9:45 am"
63
 *
64
 * @param {string} timeStr - Time portion (e.g. "3pm", "15:00", "9:30am")
65
 * @returns {{ hours: number, minutes: number } | null}
66
 */
67
function parseTimeOfDay(timeStr) {
68
  if (!timeStr) return null;
12!
69
  const cleaned = timeStr.trim().toLowerCase();
12✔
70

71
  // 12-hour: "3pm", "3:30pm", "3:30 pm", "12am"
72
  const match12 = cleaned.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/);
12✔
73
  if (match12) {
12✔
74
    let hours = Number.parseInt(match12[1], 10);
11✔
75
    const minutes = match12[2] ? Number.parseInt(match12[2], 10) : 0;
11✔
76
    const period = match12[3];
11✔
77

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

80
    if (period === 'am' && hours === 12) hours = 0;
11✔
81
    else if (period === 'pm' && hours !== 12) hours += 12;
10✔
82

83
    return { hours, minutes };
11✔
84
  }
85

86
  // 24-hour: "15:00", "09:30"
87
  const match24 = cleaned.match(/^(\d{1,2}):(\d{2})$/);
1✔
88
  if (match24) {
1!
89
    const hours = Number.parseInt(match24[1], 10);
1✔
90
    const minutes = Number.parseInt(match24[2], 10);
1✔
91
    if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
1!
92
    return { hours, minutes };
1✔
93
  }
94

NEW
95
  return null;
×
96
}
97

98
/**
99
 * Set the time-of-day on a Date, or default to 9:00 AM if no time specified.
100
 *
101
 * @param {Date} date - Target date (mutated in place)
102
 * @param {{ hours: number, minutes: number } | null} time - Parsed time, or null for 9am default
103
 * @returns {Date} The mutated date
104
 */
105
function applyTimeOfDay(date, time) {
106
  if (time) {
17✔
107
    date.setHours(time.hours, time.minutes, 0, 0);
12✔
108
  } else {
109
    date.setHours(9, 0, 0, 0);
5✔
110
  }
111
  return date;
17✔
112
}
113

114
/**
115
 * Parse a natural language time string into a Date.
116
 *
117
 * @param {string} input - Natural language time expression
118
 * @param {Date} [now] - Reference time (defaults to current time)
119
 * @returns {{ date: Date, consumed: string } | null} Parsed date and matched portion, or null
120
 */
121
export function parseTime(input, now) {
122
  if (!input || typeof input !== 'string') return null;
47✔
123

124
  const trimmed = input.trim().toLowerCase();
43✔
125
  if (!trimmed) return null;
43!
126

127
  const ref = now ? new Date(now.getTime()) : new Date();
43✔
128

129
  // Pattern 1: Shorthand — "5m", "2h", "1d", "30s", "3w"
130
  const shortMatch = trimmed.match(/^(\d+)\s*([smhdw])(?:\s|$)/);
47✔
131
  if (shortMatch) {
47✔
132
    const value = Number.parseInt(shortMatch[1], 10);
10✔
133
    const unit = shortMatch[2];
10✔
134
    if (value <= 0) return null;
10✔
135
    const ms = value * UNIT_MS[unit];
9✔
136
    if (!Number.isFinite(ms)) return null;
9!
137
    return { date: new Date(ref.getTime() + ms), consumed: shortMatch[0].trim() };
9✔
138
  }
139

140
  // Pattern 2: "in <N> <unit>" — "in 5 minutes", "in 2 hours"
141
  const inMatch = trimmed.match(/^in\s+(\d+)\s+([a-z]+)(?:\s|$)/);
33✔
142
  if (inMatch) {
33✔
143
    const value = Number.parseInt(inMatch[1], 10);
12✔
144
    const unitStr = inMatch[2];
12✔
145
    const ms = UNIT_MS[unitStr];
12✔
146
    if (!ms || value <= 0) return null;
12✔
147
    return { date: new Date(ref.getTime() + value * ms), consumed: inMatch[0].trim() };
10✔
148
  }
149

150
  // Pattern 3: "tomorrow" or "tomorrow at <time>"
151
  const tomorrowMatch = trimmed.match(/^tomorrow(?:\s+at\s+(.+?(?:\s+[ap]m)?))?(?:\s|$)/);
21✔
152
  if (tomorrowMatch) {
21✔
153
    const tomorrow = new Date(ref.getTime());
10✔
154
    tomorrow.setDate(tomorrow.getDate() + 1);
10✔
155
    const time = tomorrowMatch[1] ? parseTimeOfDay(tomorrowMatch[1]) : null;
10✔
156
    applyTimeOfDay(tomorrow, time);
10✔
157
    return { date: tomorrow, consumed: tomorrowMatch[0].trim() };
10✔
158
  }
159

160
  // Pattern 4: "next <day>" or "next <day> at <time>"
161
  const nextDayMatch = trimmed.match(/^next\s+([a-z]+)(?:\s+at\s+(.+?(?:\s+[ap]m)?))?(?:\s|$)/);
11✔
162
  if (nextDayMatch) {
11✔
163
    const dayName = nextDayMatch[1];
8✔
164
    const targetDay = DAY_NAMES[dayName];
8✔
165
    if (targetDay === undefined) return null;
8✔
166

167
    const result = new Date(ref.getTime());
7✔
168
    const currentDay = result.getDay();
7✔
169
    let daysAhead = targetDay - currentDay;
7✔
170
    if (daysAhead <= 0) daysAhead += 7;
7✔
171
    result.setDate(result.getDate() + daysAhead);
7✔
172

173
    const time = nextDayMatch[2] ? parseTimeOfDay(nextDayMatch[2]) : null;
7✔
174
    applyTimeOfDay(result, time);
8✔
175
    return { date: result, consumed: nextDayMatch[0].trim() };
8✔
176
  }
177

178
  return null;
3✔
179
}
180

181
/**
182
 * Parse a time expression from the beginning of a string and return both
183
 * the parsed date and the remaining message text.
184
 *
185
 * @param {string} input - Full input string (time expression + message)
186
 * @param {Date} [now] - Reference time
187
 * @returns {{ date: Date, message: string } | null}
188
 */
189
export function parseTimeAndMessage(input, now) {
190
  if (!input || typeof input !== 'string') return null;
10✔
191

192
  const trimmed = input.trim();
8✔
193
  const result = parseTime(trimmed, now);
8✔
194
  if (!result) return null;
8✔
195

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