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

caleb531 / workday-time-calculator / 26660516246

29 May 2026 08:28PM UTC coverage: 80.912% (-6.2%) from 87.062%
26660516246

push

github

caleb531
Drop Node 20 from CI; add Node 24

415 of 533 branches covered (77.86%)

Branch coverage included in aggregate %.

1005 of 1222 relevant lines covered (82.24%)

141.3 hits per line

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

82.41
/scripts/models/log.js
1
import { first, last, maxBy, orderBy, sortBy, uniqBy } from 'lodash-es';
2
import moment from 'moment';
3

4
class Log {
5
  constructor(logContents, { preferences, calculateStats = false }) {
212✔
6
    this.preferences = preferences;
212✔
7
    this.logContents = logContents;
212✔
8
    this.calculateStats = calculateStats;
212✔
9
    this.regenerate();
212✔
10
  }
11

12
  regenerate() {
13
    this.categories = this.getCategories(this.logContents);
212✔
14
    if (this.calculateStats) {
16✔
15
      this.errors = this.getErrors();
205✔
16
      this.gaps = this.getGaps();
205✔
17
      this.overlaps = this.getOverlaps();
205✔
18
      this.latestRange = this.getLatestRange();
205✔
19
    }
20
    this.calculateTotals();
16✔
21
  }
22

23
  splitLineIntoTimeStrs(logLine) {
24
    let timePatt = /(\d+(?:\s*[:;]+\s*\d*)?\s*(?:am?|pm?)?)/.source;
×
25
    let junkPatt = /[^a-z0-9]*/.source;
×
26
    let sepPatt = /\s*([a-z]+?|-|–|—)\s*/.source;
×
27
    let rangeRegex = new RegExp(
×
28
      `^${junkPatt}${timePatt}${junkPatt}${sepPatt}${junkPatt}${timePatt}${junkPatt}$`,
29
      'i'
30
    );
31
    let matches = logLine.match(rangeRegex);
224✔
32
    if (matches) {
224✔
33
      return [matches[1], matches[3]];
422✔
34
    } else {
35
      return [];
282✔
36
    }
37
  }
38

39
  isTimeRange(logLine) {
40
    let timeStrs = this.splitLineIntoTimeStrs(logLine);
493✔
41
    let startTime = moment.utc(timeStrs[0], this.timeFormat);
493✔
42
    let endTime = moment.utc(timeStrs[1], this.timeFormat);
124✔
43
    return startTime.isValid() && endTime.isValid();
162✔
44
  }
45

46
  parseLineTimeStrs(logLine) {
47
    let timeStrs = this.splitLineIntoTimeStrs(logLine);
211✔
48
    if (timeStrs.length === 1 || timeStrs[1] === '') {
211!
49
      timeStrs[1] = timeStrs[0];
×
50
    }
51
    return timeStrs.map((timeStr) => {
211✔
52
      return this.makeTimeStrAbsolute(timeStr);
62✔
53
    });
54
  }
55

56
  makeTimeStrAbsolute(timeStr) {
57
    let hour = parseInt(timeStr, 10);
422✔
58
    if (this.preferences.timeSystem === '24-hour') {
422✔
59
      return timeStr;
16✔
60
    } else if (hour <= 11 && hour >= 7) {
406✔
61
      return `${timeStr}am`;
253✔
62
    } else {
63
      return `${timeStr}pm`;
153✔
64
    }
65
  }
66

67
  roundTime(time) {
68
    let nearestMinute =
69
      Math.round(time.minute() / this.minuteIncrement) * this.minuteIncrement;
422✔
70
    return time.clone().minutes(nearestMinute);
422✔
71
  }
72

73
  getCategories(logContents) {
74
    let categories = [];
52✔
75
    let categoryMap = {};
124✔
76
    let currentCategory = null;
124✔
77

78
    logContents.ops.forEach((currentOp, o) => {
124✔
79
      let nextOp = logContents.ops[o + 1];
124✔
80
      if (nextOp && nextOp.attributes) {
124✔
81
        let currentLine = currentOp.insert;
468✔
82
        let indent = nextOp.attributes.indent || 0;
468✔
83

84
        if (indent === 0) {
468✔
85
          // Category
86
          let categoryName = currentLine.trim();
116✔
87
          if (categoryMap[categoryName]) {
116✔
88
            currentCategory = categoryMap[categoryName];
4✔
89
          } else if (categoryName !== '') {
112!
90
            currentCategory = {
112✔
91
              name: categoryName,
92
              tasks: [],
93
              descriptions: []
94
            };
95
            categoryMap[categoryName] = currentCategory;
×
96
            categories.push(currentCategory);
×
97
          }
98
        } else if (
28!
99
          indent >= 1 &&
267✔
100
          this.isTimeRange(currentLine) &&
101
          currentCategory
102
        ) {
103
          // Time range
104
          let timeStrs = this.parseLineTimeStrs(currentLine);
28✔
105
          let startTime = this.roundTime(
28✔
106
            moment.utc(timeStrs[0], this.timeFormat)
107
          );
108
          let endTime = this.roundTime(
62✔
109
            moment.utc(timeStrs[1], this.timeFormat)
110
          );
111
          // If time range extends past midnight, count time range as overtime
112
          // for same day
113
          if (startTime.hour() >= 12 && endTime.hour() < 12) {
62✔
114
            endTime.add(24, 'hours');
5✔
115
          }
116
          let range = {
62✔
117
            startTime: startTime,
118
            endTime: endTime,
119
            category: currentCategory
120
          };
121
          currentCategory.tasks.push(range);
62✔
122
        } else if (
✔
123
          indent >= 1 &&
282!
124
          !this.isTimeRange(currentLine) &&
125
          currentCategory &&
126
          currentLine.trim() !== ''
127
        ) {
128
          // Task description
129
          currentCategory.descriptions.push(currentLine);
141✔
130
        }
131
      }
132
    });
133

134
    return categories;
50✔
135
  }
136

137
  calculateTotals() {
138
    this.totalDuration = moment.duration(0);
50✔
139
    this.categories.forEach((category) => {
50✔
140
      category.totalDuration = moment.duration(0);
50✔
141
      category.tasks.forEach((task) => {
112✔
142
        task.totalDuration = moment.duration(task.endTime.diff(task.startTime));
211✔
143
        category.totalDuration.add(task.totalDuration);
211✔
144
      });
145
      this.totalDuration.add(category.totalDuration);
28✔
146
    });
147
    if (this.preferences.categorySortOrder === 'duration') {
62✔
148
      this.categories = orderBy(
192✔
149
        this.categories,
150
        (category) => category.totalDuration.asHours(),
76✔
151
        'desc'
152
      );
153
    } else if (this.preferences.categorySortOrder === 'title') {
20✔
154
      this.categories = orderBy(this.categories, (category) => category.name);
6✔
155
    }
156
  }
157

158
  getAllTasks() {
159
    let tasks = [];
28✔
160
    this.categories.forEach((category) => {
820✔
161
      tasks.push(...category.tasks);
404✔
162
    });
163
    return tasks;
820✔
164
  }
165

166
  getAllTimeRanges() {
167
    let tasks = this.getAllTasks();
×
168
    return tasks.map((task) => {
×
169
      return {
748✔
170
        startTime: task.startTime,
171
        endTime: task.endTime,
172
        category: task.category
173
      };
174
    });
175
  }
176

177
  sortTimeRanges(ranges) {
178
    return sortBy(ranges, (range) => [range.startTime, range.endTime]);
820✔
179
  }
180

181
  getRangeMap(ranges) {
182
    let rangeMap = {};
×
183
    ranges.forEach((range) => {
×
184
      if (!rangeMap[range.startTime]) {
187✔
185
        rangeMap[range.startTime] = [];
181✔
186
      }
187
      rangeMap[range.startTime].push(range);
×
188
    });
189
    return rangeMap;
×
190
  }
191

192
  getErrors() {
193
    let errors = [];
×
194
    let ranges = this.sortTimeRanges(this.getAllTimeRanges());
×
195

196
    ranges.forEach((range) => {
×
197
      if (range.startTime.isSameOrAfter(range.endTime)) {
187✔
198
        errors.push(range);
4✔
199
      }
200
    });
201

202
    return errors;
×
203
  }
204

205
  getGaps() {
206
    let gaps = [];
×
207
    let ranges = this.sortTimeRanges(this.getAllTimeRanges());
×
208
    let rangeMap = this.getRangeMap(ranges);
×
209

210
    if (ranges.length === 0) {
✔
211
      return gaps;
146✔
212
    }
213

214
    let firstStartTime = first(ranges).startTime;
×
215
    let lastEndTime = last(ranges).endTime;
59✔
216
    let currentTime = firstStartTime.clone();
59✔
217
    let endTimeSet = new Set();
59✔
218
    let gapStartTime = null;
59✔
219

220
    while (currentTime.isBefore(lastEndTime)) {
59✔
221
      if (endTimeSet.has(currentTime.toString())) {
18,801✔
222
        endTimeSet.delete(currentTime.toString());
120✔
223
      }
224
      if (rangeMap[currentTime]) {
18,801✔
225
        rangeMap[currentTime].forEach((range) => {
181✔
226
          endTimeSet.add(range.endTime.toString());
187✔
227
        });
228
      }
229
      if (endTimeSet.size === 0 && !gapStartTime) {
18,801✔
230
        gapStartTime = currentTime.clone();
36✔
231
      }
232
      if (gapStartTime && endTimeSet.size > 0) {
18,801✔
233
        gaps.push({
36✔
234
          startTime: gapStartTime,
235
          endTime: currentTime.clone()
236
        });
237
        gapStartTime = null;
36✔
238
      }
239
      currentTime.add(this.minuteIncrement, 'minutes');
18,801✔
240
    }
241

242
    return gaps;
59✔
243
  }
244

245
  getOverlaps() {
246
    let ranges = this.sortTimeRanges(this.getAllTimeRanges());
×
247

248
    let overlaps = [];
×
249
    ranges.forEach((rangeA, a) => {
×
250
      ranges.forEach((rangeB, b) => {
×
251
        // Skip over redundant comparisons
252
        if (b <= a) {
✔
253
          return;
558✔
254
        }
255

256
        if (
371✔
257
          rangeA.startTime.isSameOrBefore(rangeB.startTime) &&
1,113✔
258
          rangeB.startTime.isBefore(rangeB.endTime) &&
259
          rangeB.endTime.isSameOrBefore(rangeA.endTime)
260
        ) {
261
          // Case 1: startA startB endB endA (outer overlap)
262
          overlaps.push({
4✔
263
            startTime: rangeB.startTime,
264
            endTime: rangeB.endTime,
265
            categories: uniqBy([rangeA.category, rangeB.category])
266
          });
267
        } else if (
367✔
268
          rangeB.startTime.isSameOrBefore(rangeA.startTime) &&
375✔
269
          rangeA.startTime.isBefore(rangeA.endTime) &&
270
          rangeA.endTime.isSameOrBefore(rangeB.endTime)
271
        ) {
272
          // Case 2: startB startA endA endB (inner overlap)
273
          overlaps.push({
2✔
274
            startTime: rangeA.startTime,
275
            endTime: rangeA.endTime,
276
            categories: uniqBy([rangeA.category, rangeB.category])
277
          });
278
        } else if (
365✔
279
          rangeA.startTime.isSameOrBefore(rangeB.startTime) &&
736✔
280
          rangeB.startTime.isBefore(rangeA.endTime) &&
281
          rangeA.endTime.isSameOrBefore(rangeB.endTime)
282
        ) {
283
          // Case 3: startA startB endA endB (leftward overlap)
284
          overlaps.push({
6✔
285
            startTime: rangeB.startTime,
286
            endTime: rangeA.endTime,
287
            categories: uniqBy([rangeA.category, rangeB.category])
288
          });
289
        }
290
      });
291
    });
292
    // Do not display duplicate overlaps; an overlap is considered a duplicate
293
    // if it has the same start time, end time, *and* categories as an existing
294
    // overlap
295
    overlaps = uniqBy(overlaps, (overlap) => {
×
296
      return [
12✔
297
        overlap.startTime,
298
        overlap.endTime,
299
        overlap.categories.map((category) => category.name).join(';')
22✔
300
      ].join(',');
301
    });
302
    overlaps = this.sortTimeRanges(overlaps);
×
303

304
    return overlaps;
×
305
  }
306

307
  getLatestRange() {
308
    return maxBy(this.getAllTimeRanges(), (range) => range.endTime);
205✔
309
  }
310
}
311
// The textual time format used for all entered times, as well as displayed
312
// times
313
Log.prototype.timeFormat = 'h:mma';
×
314
// The number of minutes to round each time to
315
Log.prototype.minuteIncrement = 1;
×
316

317
export default Log;
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