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

caleb531 / workday-time-calculator / 24473791617

15 Apr 2026 07:23PM UTC coverage: 85.183%. First build
24473791617

push

github

caleb531
Add native date input-like keyboard navigation to custom date input

590 of 656 branches covered (89.94%)

Branch coverage included in aggregate %.

164 of 282 new or added lines in 1 file covered. (58.16%)

2434 of 2894 relevant lines covered (84.11%)

149.37 hits per line

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

69.16
/scripts/components/date-input.jsx
1
import m from 'mithril';
2✔
2
import moment from 'moment';
2✔
3
import CalendarIconComponent from './calendar-icon.jsx';
2✔
4
import CalendarComponent from './calendar.jsx';
2✔
5

6
// Analytics-only custom date field that emulates native segmented date editing
7
// while still using the app's custom calendar popup.
8
class DateInputComponent {
2✔
9
  oninit({ attrs }) {
2✔
10
    // Track whether the popup calendar is currently visible.
11
    this.calendarOpen = false;
40✔
12
    // Hold the delayed-close timer id used after selecting a date from the calendar.
13
    this.closeCalendarTimeoutId = null;
40✔
14
    // Remember focus state so segment selection can be restored after redraws.
15
    this.isInputFocused = false;
40✔
16
    // The currently selected editable segment inside the MM/DD/YYYY display.
17
    this.activeSegment = 'month';
40✔
18
    // Buffer numeric keystrokes so segments can be typed progressively.
19
    this.segmentInputBuffer = '';
40✔
20
    // Record which segment owns the current buffered digits.
21
    this.segmentInputBufferSegment = null;
40✔
22
    // Timestamp the last buffered keystroke to decide whether to append or restart.
23
    this.segmentInputBufferUpdatedAt = 0;
40✔
24
    // Schedule selection updates after Mithril redraws and browser focus changes.
25
    this.selectionFrameId = null;
40✔
26
    this.onbeforeupdate({ attrs });
40✔
27
  }
40✔
28

29
  onremove() {
2✔
30
    // Clean up async work so the component does not mutate state after unmount.
31
    this.clearCloseCalendarTimeout();
40✔
32
    this.clearScheduledSelection();
40✔
33
  }
40✔
34

35
  onbeforeupdate({ attrs: { value, onChange } }) {
2✔
36
    // The public value stays normalized as YYYY-MM-DD even though the field is
37
    // rendered and edited as MM/DD/YYYY.
38
    const parsedValue = moment(value, 'YYYY-MM-DD', true);
138✔
39
    if (parsedValue.isValid()) {
138✔
40
      this.selectedDate = parsedValue;
138✔
41
    }
138✔
42
    this.value = value;
138✔
43
    this.onChange = onChange;
138✔
44
  }
138✔
45

46
  clearScheduledSelection() {
2✔
47
    // Cancel the pending segment-selection frame when a newer one supersedes it.
48
    if (this.selectionFrameId) {
105✔
49
      window.cancelAnimationFrame(this.selectionFrameId);
48✔
50
      this.selectionFrameId = null;
48✔
51
    }
48✔
52
  }
105✔
53

54
  scheduleActiveSegmentSelection() {
2✔
55
    // Selection needs to happen after focus, click, and redraw side effects have
56
    // settled, otherwise the browser may immediately overwrite it.
57
    this.clearScheduledSelection();
53✔
58
    this.selectionFrameId = window.requestAnimationFrame(() => {
53✔
59
      this.selectionFrameId = null;
17✔
60
      this.selectActiveSegment();
17✔
61
    });
53✔
62
  }
53✔
63

64
  scheduleSegmentSelectionFromCaret(caretPosition) {
2✔
65
    // Clicking the input should choose a segment immediately, but the browser
66
    // may not have finalized the caret position until after mousedown logic
67
    // finishes. Re-resolve the segment on the next frame using the latest caret.
68
    this.clearScheduledSelection();
12✔
69
    this.selectionFrameId = window.requestAnimationFrame(() => {
12✔
NEW
70
      this.selectionFrameId = null;
×
NEW
71
      this.activeSegment = this.getSegmentFromCaretPosition(caretPosition());
×
NEW
72
      this.selectActiveSegment();
×
73
    });
12✔
74
  }
12✔
75

76
  setInputElement(dom) {
2✔
77
    // Keep a direct reference because selection is managed imperatively.
78
    this.inputElement = dom;
138✔
79
    if (this.isInputFocused) {
138✔
80
      this.scheduleActiveSegmentSelection();
19✔
81
    }
19✔
82
  }
138✔
83

84
  get segmentRanges() {
2✔
85
    // Character ranges for each editable segment within MM/DD/YYYY.
86
    return {
16✔
87
      month: [0, 2],
16✔
88
      day: [3, 5],
16✔
89
      year: [6, 10]
16✔
90
    };
16✔
91
  }
16✔
92

93
  get segmentOrder() {
2✔
94
    // Canonical navigation order for arrow-key movement across segments.
95
    return ['month', 'day', 'year'];
30✔
96
  }
30✔
97

98
  getSegmentLength(segment) {
2✔
99
    // Month and day are two digits, while year is four digits.
NEW
100
    return segment === 'year' ? 4 : 2;
×
NEW
101
  }
×
102

103
  getDisplayValue() {
2✔
104
    // Render the currently committed date, optionally overlaying any in-progress
105
    // buffered digits for the active segment.
106
    if (!this.selectedDate) {
142!
NEW
107
      return '';
×
NEW
108
    }
×
109

110
    let displayValue = this.selectedDate.format('MM/DD/YYYY');
142✔
111
    if (this.segmentInputBuffer && this.segmentInputBufferSegment) {
142!
NEW
112
      displayValue = this.replaceSegmentInDisplayValue(
×
NEW
113
        displayValue,
×
NEW
114
        this.segmentInputBufferSegment,
×
NEW
115
        this.segmentInputBuffer
×
NEW
116
      );
×
NEW
117
    }
×
118
    return displayValue;
142✔
119
  }
142✔
120

121
  replaceSegmentInDisplayValue(displayValue, segment, segmentValue) {
2✔
122
    // Replace one segment without disturbing the slash separators or the other
123
    // committed segments in the display value.
NEW
124
    const [segmentStart, segmentEnd] = this.segmentRanges[segment];
×
NEW
125
    const paddedValue = segmentValue
×
NEW
126
      .padStart(this.getSegmentLength(segment), '0')
×
NEW
127
      .slice(-this.getSegmentLength(segment));
×
NEW
128
    return (
×
NEW
129
      displayValue.slice(0, segmentStart) +
×
NEW
130
      paddedValue +
×
NEW
131
      displayValue.slice(segmentEnd)
×
132
    );
NEW
133
  }
×
134

135
  selectActiveSegment() {
2✔
136
    // Keep the field feeling like a segmented control by always selecting the
137
    // whole active segment and never leaving behind a free-moving caret.
138
    if (!this.inputElement || document.activeElement !== this.inputElement) {
17✔
139
      return;
1✔
140
    }
1✔
141

142
    const [selectionStart, selectionEnd] =
16✔
143
      this.segmentRanges[this.activeSegment];
16✔
144
    this.inputElement.setSelectionRange(selectionStart, selectionEnd);
16✔
145
  }
17✔
146

147
  setActiveSegment(segment) {
2✔
148
    // Changing segments resets any half-entered numeric buffer so a new segment
149
    // always starts from a clean editing state.
150
    if (this.activeSegment !== segment) {
18✔
151
      this.resetSegmentInputBuffer();
12✔
152
    }
12✔
153
    this.activeSegment = segment;
18✔
154
    this.scheduleActiveSegmentSelection();
18✔
155
  }
18✔
156

157
  moveActiveSegment(offset) {
2✔
158
    // Move left or right while staying within the first and last segment.
NEW
159
    const currentIndex = this.segmentOrder.indexOf(this.activeSegment);
×
NEW
160
    const nextIndex = Math.min(
×
NEW
161
      this.segmentOrder.length - 1,
×
NEW
162
      Math.max(0, currentIndex + offset)
×
NEW
163
    );
×
NEW
164
    this.setActiveSegment(this.segmentOrder[nextIndex]);
×
NEW
165
  }
×
166

167
  getSegmentFromCaretPosition(caretPosition) {
2✔
168
    // Map click/caret positions back to the nearest logical segment.
NEW
169
    if (caretPosition <= 2) {
×
NEW
170
      return 'month';
×
NEW
171
    }
×
NEW
172
    if (caretPosition <= 5) {
×
NEW
173
      return 'day';
×
NEW
174
    }
×
NEW
175
    return 'year';
×
NEW
176
  }
×
177

178
  getSegmentParts(date = this.selectedDate) {
2✔
179
    // Work with plain numeric parts when performing segment-level edits.
180
    return {
2✔
181
      month: date.month() + 1,
2✔
182
      day: date.date(),
2✔
183
      year: date.year()
2✔
184
    };
2✔
185
  }
2✔
186

187
  createDateFromParts({ month, day, year }) {
2✔
188
    // Clamp the resulting parts into a valid Gregorian date so edits like moving
189
    // from January 31 to February produce a valid last day of the month.
190
    const clampedYear = Math.max(1, year);
2✔
191
    const clampedMonth = Math.min(12, Math.max(1, month));
2✔
192
    const daysInMonth = moment([clampedYear, clampedMonth - 1]).daysInMonth();
2✔
193
    const clampedDay = Math.min(daysInMonth, Math.max(1, day));
2✔
194
    return moment([clampedYear, clampedMonth - 1, clampedDay]);
2✔
195
  }
2✔
196

197
  commitSelectedDate(selectedDate) {
2✔
198
    // Persist the canonical date back to the parent component in normalized form.
199
    this.selectedDate = selectedDate.clone();
6✔
200
    this.value = this.selectedDate.format('YYYY-MM-DD');
6✔
201
    this.onChange(this.value);
6✔
202
  }
6✔
203

204
  commitSegmentValue(segment, segmentValue) {
2✔
205
    // Apply an edited segment while preserving the other two segments.
NEW
206
    const parts = this.getSegmentParts();
×
NEW
207
    if (segment === 'month') {
×
NEW
208
      parts.month = Math.min(12, Math.max(1, segmentValue));
×
NEW
209
    } else if (segment === 'day') {
×
NEW
210
      const daysInMonth = moment([parts.year, parts.month - 1]).daysInMonth();
×
NEW
211
      parts.day = Math.min(daysInMonth, Math.max(1, segmentValue));
×
NEW
212
    } else {
×
NEW
213
      parts.year = Math.max(1, segmentValue);
×
NEW
214
    }
×
NEW
215
    this.commitSelectedDate(this.createDateFromParts(parts));
×
NEW
216
  }
×
217

218
  incrementActiveSegment(delta) {
2✔
219
    // Arrow keys step the active segment and wrap month/day like native desktop
220
    // date fields while clamping year at 1.
221
    const parts = this.getSegmentParts();
2✔
222

223
    if (this.activeSegment === 'month') {
2✔
224
      parts.month = ((parts.month - 1 + delta + 12) % 12) + 1;
2✔
225
    } else if (this.activeSegment === 'day') {
2!
NEW
226
      const daysInMonth = moment([parts.year, parts.month - 1]).daysInMonth();
×
NEW
227
      parts.day = ((parts.day - 1 + delta + daysInMonth) % daysInMonth) + 1;
×
NEW
228
    } else {
×
NEW
229
      parts.year = Math.max(1, parts.year + delta);
×
NEW
230
    }
×
231

232
    this.resetSegmentInputBuffer();
2✔
233
    this.commitSelectedDate(this.createDateFromParts(parts));
2✔
234
    this.scheduleActiveSegmentSelection();
2✔
235
  }
2✔
236

237
  resetSegmentInputBuffer() {
2✔
238
    // Discard any partially typed digits for the current segment.
239
    this.segmentInputBuffer = '';
22✔
240
    this.segmentInputBufferSegment = null;
22✔
241
    this.segmentInputBufferUpdatedAt = 0;
22✔
242
  }
22✔
243

244
  updateSegmentInputBuffer(digit) {
2✔
245
    // Append digits typed in quick succession to the same segment; otherwise,
246
    // start a new segment edit.
NEW
247
    const now = Date.now();
×
NEW
248
    const shouldAppendToBuffer =
×
NEW
249
      this.segmentInputBufferSegment === this.activeSegment &&
×
NEW
250
      now - this.segmentInputBufferUpdatedAt < 1500 &&
×
NEW
251
      this.segmentInputBuffer.length < this.getSegmentLength(this.activeSegment);
×
252

NEW
253
    this.segmentInputBuffer = shouldAppendToBuffer
×
NEW
254
      ? `${this.segmentInputBuffer}${digit}`
×
NEW
255
      : digit;
×
NEW
256
    this.segmentInputBufferSegment = this.activeSegment;
×
NEW
257
    this.segmentInputBufferUpdatedAt = now;
×
NEW
258
  }
×
259

260
  handleDigitKey(digit) {
2✔
261
    // Numeric typing edits the selected segment rather than inserting freeform
262
    // text into the field.
NEW
263
    this.updateSegmentInputBuffer(digit);
×
264

NEW
265
    if (this.activeSegment === 'year') {
×
266
      // Year waits for all four digits before committing.
NEW
267
      if (this.segmentInputBuffer.length === 4) {
×
NEW
268
        this.commitSegmentValue('year', parseInt(this.segmentInputBuffer, 10));
×
NEW
269
        this.resetSegmentInputBuffer();
×
NEW
270
      }
×
NEW
271
      this.scheduleActiveSegmentSelection();
×
NEW
272
      return;
×
NEW
273
    }
×
274

NEW
275
    const numericValue = parseInt(this.segmentInputBuffer, 10);
×
276
    // Month/day allow a brief pause after digits that could still lead to a
277
    // valid two-digit value, such as 0_, 1_, 2_, or 3_.
NEW
278
    const isWaitingForSecondDigit =
×
NEW
279
      this.segmentInputBuffer === '0' ||
×
NEW
280
      (this.activeSegment === 'month' && numericValue <= 1) ||
×
NEW
281
      (this.activeSegment === 'day' && numericValue <= 3);
×
282

NEW
283
    if (this.segmentInputBuffer.length === 1 && isWaitingForSecondDigit) {
×
NEW
284
      this.scheduleActiveSegmentSelection();
×
NEW
285
      return;
×
NEW
286
    }
×
287

NEW
288
    this.commitSegmentValue(this.activeSegment, numericValue);
×
NEW
289
    this.resetSegmentInputBuffer();
×
NEW
290
    this.moveActiveSegment(1);
×
NEW
291
  }
×
292

293
  parseAcceptedDateInput(inputValue) {
2✔
294
    // Free typing and paste still accept a few common date formats and normalize
295
    // them back into the field's canonical display format.
296
    const parsedValue = moment(
2✔
297
      inputValue,
2✔
298
      ['MM/DD/YYYY', 'M/D/YYYY', 'YYYY-MM-DD'],
2✔
299
      true
2✔
300
    );
2✔
301
    return parsedValue.isValid() ? parsedValue : null;
2!
302
  }
2✔
303

304
  toggleCalendar() {
2✔
305
    // Toggle from the calendar button without leaving stale delayed-close timers.
306
    this.clearCloseCalendarTimeout();
2✔
307
    this.calendarOpen = !this.calendarOpen;
2✔
308
  }
2✔
309

310
  closeCalendar() {
2✔
311
    // Close immediately, typically after outside clicks.
312
    this.clearCloseCalendarTimeout();
×
313
    this.calendarOpen = false;
×
314
  }
×
315

316
  clearCloseCalendarTimeout() {
2✔
317
    // Ensure only one delayed-close timer exists at a time.
318
    if (this.closeCalendarTimeoutId) {
44✔
319
      window.clearTimeout(this.closeCalendarTimeoutId);
2✔
320
      this.closeCalendarTimeoutId = null;
2✔
321
    }
2✔
322
  }
44✔
323

324
  closeCalendarWithDelay() {
2✔
325
    // Leave the calendar visible briefly after selecting a date so the click
326
    // feels acknowledged before the popup disappears.
327
    this.clearCloseCalendarTimeout();
2✔
328
    this.closeCalendarTimeoutId = window.setTimeout(() => {
2✔
329
      this.calendarOpen = false;
×
330
      this.closeCalendarTimeoutId = null;
×
331
      m.redraw();
×
332
    }, 250);
2✔
333
  }
2✔
334

335
  openCalendar() {
2✔
336
    // Open from the calendar button only; focusing the input should not pop the
337
    // calendar because it interferes with segmented keyboard editing.
338
    this.clearCloseCalendarTimeout();
×
339
    this.calendarOpen = true;
×
340
  }
×
341

342
  handleInput(event) {
2✔
343
    // Allow direct typing of full date strings as a fallback for users who paste
344
    // or overwrite the field value instead of using segment navigation.
345
    const parsedValue = this.parseAcceptedDateInput(event.target.value);
2✔
346
    if (parsedValue) {
2✔
347
      this.resetSegmentInputBuffer();
2✔
348
      this.commitSelectedDate(parsedValue);
2✔
349
    }
2✔
350
  }
2✔
351

352
  handleBlur() {
2✔
353
    // On blur, discard transient segment edits and snap the field back to the
354
    // last committed valid date.
355
    this.isInputFocused = false;
4✔
356
    this.resetSegmentInputBuffer();
4✔
357
    if (this.inputElement) {
4✔
358
      this.inputElement.value = this.getDisplayValue();
4✔
359
    }
4✔
360
  }
4✔
361

362
  handleFocus() {
2✔
363
    // Focusing the field selects the active segment instead of showing a caret.
364
    this.isInputFocused = true;
14✔
365
    this.scheduleActiveSegmentSelection();
14✔
366
  }
14✔
367

368
  handleMouseDown(event) {
2✔
369
    // Resolve the clicked segment from the caret position established by the
370
    // browser's default mousedown focus logic, then immediately reselect the
371
    // whole segment.
372
    this.isInputFocused = true;
12✔
373
    this.scheduleSegmentSelectionFromCaret(() => {
12✔
NEW
374
      return event.target.selectionStart || 0;
×
375
    });
12✔
376
  }
12✔
377

378
  handleKeyDown(event) {
2✔
379
    // The field owns keyboard semantics, so most editing/navigation keys are
380
    // intercepted and mapped to segment operations.
381
    if (!this.selectedDate) {
26!
NEW
382
      return;
×
NEW
383
    }
×
384

385
    if (/^[0-9]$/.test(event.key)) {
26!
NEW
386
      event.preventDefault();
×
NEW
387
      this.handleDigitKey(event.key);
×
NEW
388
      return;
×
NEW
389
    }
×
390

391
    if (event.key === 'ArrowLeft') {
26!
NEW
392
      event.preventDefault();
×
NEW
393
      this.moveActiveSegment(-1);
×
NEW
394
      return;
×
NEW
395
    }
×
396

397
    if (event.key === 'ArrowRight') {
26!
NEW
398
      event.preventDefault();
×
NEW
399
      this.moveActiveSegment(1);
×
NEW
400
      return;
×
NEW
401
    }
×
402

403
    if (event.key === 'ArrowUp') {
26✔
404
      event.preventDefault();
2✔
405
      this.incrementActiveSegment(1);
2✔
406
      return;
2✔
407
    }
2✔
408

409
    if (event.key === 'ArrowDown') {
26!
NEW
410
      event.preventDefault();
×
NEW
411
      this.incrementActiveSegment(-1);
×
NEW
412
      return;
×
NEW
413
    }
✔
414

415
    if (event.key === 'Home') {
26✔
416
      event.preventDefault();
6✔
417
      this.setActiveSegment('month');
6✔
418
      return;
6✔
419
    }
6✔
420

421
    if (event.key === 'End') {
26✔
422
      event.preventDefault();
4✔
423
      this.setActiveSegment('year');
4✔
424
      return;
4✔
425
    }
4✔
426

427
    if (event.key === 'Tab') {
26✔
428
      const currentIndex = this.segmentOrder.indexOf(this.activeSegment);
12✔
429
      const nextIndex = event.shiftKey ? currentIndex - 1 : currentIndex + 1;
12✔
430

431
      if (nextIndex >= 0 && nextIndex < this.segmentOrder.length) {
12✔
432
        event.preventDefault();
8✔
433
        this.setActiveSegment(this.segmentOrder[nextIndex]);
8✔
434
      }
8✔
435
      return;
12✔
436
    }
12✔
437

438
    if (event.key === 'Backspace' || event.key === 'Delete') {
26!
NEW
439
      event.preventDefault();
×
NEW
440
      this.resetSegmentInputBuffer();
×
NEW
441
      this.scheduleActiveSegmentSelection();
×
NEW
442
    }
×
443
  }
26✔
444

445
  handlePaste(event) {
2✔
446
    // Normalized paste support keeps the custom field usable for power users.
NEW
447
    const pastedText = event.clipboardData.getData('text');
×
NEW
448
    const parsedValue = this.parseAcceptedDateInput(pastedText);
×
NEW
449
    if (!parsedValue) {
×
NEW
450
      return;
×
NEW
451
    }
×
452

NEW
453
    event.preventDefault();
×
NEW
454
    this.resetSegmentInputBuffer();
×
NEW
455
    this.commitSelectedDate(parsedValue);
×
NEW
456
    this.scheduleActiveSegmentSelection();
×
457
  }
×
458

459
  setSelectedDate(selectedDate) {
2✔
460
    // Calendar selections flow through the same commit path as keyboard edits.
461
    this.resetSegmentInputBuffer();
2✔
462
    this.commitSelectedDate(selectedDate);
2✔
463
    this.closeCalendarWithDelay();
2✔
464
  }
2✔
465

466
  view({ attrs }) {
2✔
467
    // The component exposes only an aria-label; styling and behavior are owned
468
    // internally so consumers cannot accidentally break the segmented UX.
469
    const ariaLabel = attrs['aria-label'];
138✔
470
    return (
138✔
471
      <div className="date-input">
138✔
472
        <input
138✔
473
          type="text"
138✔
474
          className="date-input-input"
138✔
475
          aria-label={ariaLabel}
138✔
476
          placeholder="MM/DD/YYYY"
138✔
477
          aria-haspopup="dialog"
138✔
478
          aria-expanded={this.calendarOpen ? 'true' : 'false'}
138✔
479
          value={this.getDisplayValue()}
138✔
480
          onfocus={() => this.handleFocus()}
138✔
481
          onblur={() => this.handleBlur()}
138✔
482
          onmousedown={(event) => this.handleMouseDown(event)}
138✔
483
          onkeydown={(event) => this.handleKeyDown(event)}
138✔
484
          oninput={(event) => this.handleInput(event)}
138✔
485
          onpaste={(event) => this.handlePaste(event)}
138✔
486
          oncreate={({ dom }) => this.setInputElement(dom)}
138✔
487
          onupdate={({ dom }) => this.setInputElement(dom)}
138✔
488
        />
138✔
489
        <button
138✔
490
          type="button"
138✔
491
          className="date-input-calendar-toggle"
138✔
492
          aria-label={`Open ${ariaLabel} Calendar`}
138✔
493
          aria-haspopup="dialog"
138✔
494
          aria-expanded={this.calendarOpen ? 'true' : 'false'}
138✔
495
          onclick={() => this.toggleCalendar()}
138✔
496
        >
497
          <CalendarIconComponent selectedDate={this.selectedDate} />
138✔
498
        </button>
138✔
499

500
        {this.selectedDate && this.calendarOpen ? (
138✔
501
          <CalendarComponent
4✔
502
            className="date-input-calendar"
4✔
503
            selectedDate={this.selectedDate}
4✔
504
            calendarOpen={this.calendarOpen}
4✔
505
            onShouldIgnoreOutsideClick={(target) => {
4✔
506
              // Clicking the calendar toggle button should not be treated as an
507
              // outside click by the popup close handler.
508
              let element = target;
×
509
              while (element && element !== document) {
×
510
                if (
×
511
                  element.classList &&
×
512
                  element.classList.contains('date-input-calendar-toggle')
×
513
                ) {
×
514
                  return true;
×
515
                }
×
516
                element = element.parentNode;
×
517
              }
×
518
              return false;
×
519
            }}
×
520
            onSetSelectedDate={(selectedDate) => {
4✔
521
              this.setSelectedDate(selectedDate);
2✔
522
            }}
2✔
523
            onCloseCalendar={() => {
4✔
524
              this.closeCalendar();
×
525
            }}
×
526
          />
4✔
527
        ) : null}
134✔
528
      </div>
138✔
529
    );
530
  }
138✔
531
}
2✔
532

533
export default DateInputComponent;
2✔
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