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

caleb531 / workday-time-calculator / 31065498038

06 Aug 2026 02:23AM UTC coverage: 81.159% (-0.6%) from 81.777%
31065498038

push

github

caleb531
Remove uses of needless head() and last() functions

Just use native array indexing.

419 of 539 branches covered (77.74%)

Branch coverage included in aggregate %.

1 of 2 new or added lines in 1 file covered. (50.0%)

8 existing lines in 1 file now uncovered.

1024 of 1239 relevant lines covered (82.65%)

286.45 hits per line

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

83.11
/scripts/models/log.js
1
import { maxBy, orderBy, sortBy, uniqBy } from 'es-toolkit';
2
import moment from 'moment';
3

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

12
  regenerate() {
13
    this.categories = this.getCategories(this.logContents);
422✔
14
    if (this.calculateStats) {
53✔
15
      this.errors = this.getErrors();
416✔
16
      this.gaps = this.getGaps();
416✔
17
      this.overlaps = this.getOverlaps();
416✔
18
      this.latestRange = this.getLatestRange();
416✔
19
    }
20
    this.calculateTotals();
53✔
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);
700✔
32
    if (matches) {
700✔
33
      return [matches[1], matches[3]];
792✔
34
    } else {
35
      return [];
528✔
36
    }
37
  }
38

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

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

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

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

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

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

84
        if (indent === 0) {
880✔
85
          // Category
86
          let categoryName = currentLine.trim();
220✔
87
          if (categoryMap[categoryName]) {
220✔
88
            currentCategory = categoryMap[categoryName];
8✔
89
          } else if (categoryName !== '') {
212!
90
            currentCategory = {
212✔
91
              name: categoryName,
92
              tasks: [],
93
              descriptions: []
94
            };
95
            categoryMap[categoryName] = currentCategory;
×
96
            categories.push(currentCategory);
×
97
          }
98
        } else if (
89!
99
          indent >= 1 &&
574✔
100
          this.isTimeRange(currentLine) &&
101
          currentCategory
102
        ) {
103
          // Time range
104
          let timeStrs = this.parseLineTimeStrs(currentLine);
89✔
105
          let startTime = this.roundTime(
89✔
106
            moment.utc(timeStrs[0], this.timeFormat)
107
          );
108
          let endTime = this.roundTime(
196✔
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) {
196✔
114
            endTime.add(24, 'hours');
10✔
115
          }
116
          let range = {
196✔
117
            startTime: startTime,
118
            endTime: endTime,
119
            category: currentCategory
120
          };
121
          currentCategory.tasks.push(range);
196✔
122
        } else if (
✔
123
          indent >= 1 &&
528!
124
          !this.isTimeRange(currentLine) &&
125
          currentCategory &&
126
          currentLine.trim() !== ''
127
        ) {
128
          // Task description
129
          currentCategory.descriptions.push(currentLine);
264✔
130
        }
131
      }
132
    });
133

134
    return categories;
154✔
135
  }
136

137
  calculateTotals() {
138
    this.totalDuration = moment.duration(0);
154✔
139
    this.categories.forEach((category) => {
154✔
140
      category.totalDuration = moment.duration(0);
154✔
141
      category.tasks.forEach((task) => {
212✔
142
        task.totalDuration = moment.duration(task.endTime.diff(task.startTime));
396✔
143
        category.totalDuration.add(task.totalDuration);
396✔
144
      });
145
      this.totalDuration.add(category.totalDuration);
89✔
146
    });
147
    if (this.preferences.categorySortOrder === 'duration') {
196✔
148
      this.categories = orderBy(
382✔
149
        this.categories,
150
        [(category) => category.totalDuration.asHours()],
168✔
151
        ['desc']
152
      );
153
    } else if (this.preferences.categorySortOrder === 'title') {
40✔
154
      this.categories = orderBy(
4✔
155
        this.categories,
156
        [(category) => category.name],
32✔
157
        ['asc']
158
      );
159
    }
160
  }
161

162
  getAllTasks() {
163
    let tasks = [];
1,664✔
164
    this.categories.forEach((category) => {
1,664✔
165
      tasks.push(...category.tasks);
808✔
166
    });
167
    return tasks;
1,664✔
168
  }
169

170
  getAllTimeRanges() {
171
    let tasks = this.getAllTasks();
×
172
    return tasks.map((task) => {
×
173
      return {
1,496✔
174
        startTime: task.startTime,
175
        endTime: task.endTime,
176
        category: task.category
177
      };
178
    });
179
  }
180

181
  sortTimeRanges(ranges) {
182
    return sortBy(ranges, [
1,664✔
183
      (range) => range.startTime,
2,420✔
184
      (range) => range.endTime
80✔
185
    ]);
186
  }
187

188
  getRangeMap(ranges) {
189
    let rangeMap = {};
×
190
    ranges.forEach((range) => {
×
191
      if (!rangeMap[range.startTime]) {
374✔
192
        rangeMap[range.startTime] = [];
362✔
193
      }
194
      rangeMap[range.startTime].push(range);
374✔
195
    });
196
    return rangeMap;
×
197
  }
198

199
  getErrors() {
200
    let errors = [];
×
201
    let ranges = this.sortTimeRanges(this.getAllTimeRanges());
×
202

203
    ranges.forEach((range) => {
×
204
      if (range.startTime.isSameOrAfter(range.endTime)) {
374✔
205
        errors.push(range);
8✔
206
      }
207
    });
208

209
    return errors;
×
210
  }
211

212
  getGaps() {
213
    let gaps = [];
×
214
    let ranges = this.sortTimeRanges(this.getAllTimeRanges());
×
215
    let rangeMap = this.getRangeMap(ranges);
×
216

217
    if (ranges.length === 0) {
✔
218
      return gaps;
298✔
219
    }
220

NEW
221
    let firstStartTime = ranges[0].startTime;
×
222
    let lastEndTime = ranges[ranges.length - 1].endTime;
118✔
223
    let currentTime = firstStartTime.clone();
118✔
224
    let endTimeSet = new Set();
118✔
225
    let gapStartTime = null;
118✔
226

227
    while (currentTime.isBefore(lastEndTime)) {
118✔
228
      if (endTimeSet.has(currentTime.toString())) {
37,602✔
229
        endTimeSet.delete(currentTime.toString());
240✔
230
      }
231
      if (rangeMap[currentTime]) {
37,602✔
232
        rangeMap[currentTime].forEach((range) => {
362✔
233
          endTimeSet.add(range.endTime.toString());
374✔
234
        });
235
      }
236
      if (endTimeSet.size === 0 && !gapStartTime) {
37,602✔
237
        gapStartTime = currentTime.clone();
72✔
238
      }
239
      if (gapStartTime && endTimeSet.size > 0) {
37,602✔
240
        gaps.push({
72✔
241
          startTime: gapStartTime,
242
          endTime: currentTime.clone()
243
        });
244
        gapStartTime = null;
72✔
245
      }
246
      currentTime.add(this.minuteIncrement, 'minutes');
37,602✔
247
    }
248

249
    return gaps;
118✔
250
  }
251

252
  getOverlaps() {
253
    let ranges = this.sortTimeRanges(this.getAllTimeRanges());
×
254

255
    let overlaps = [];
×
256
    ranges.forEach((rangeA, a) => {
×
257
      ranges.forEach((rangeB, b) => {
×
258
        // Skip over redundant comparisons
259
        if (b <= a) {
✔
260
          return;
1,116✔
261
        }
262

263
        if (
742✔
264
          rangeA.startTime.isSameOrBefore(rangeB.startTime) &&
2,226✔
265
          rangeB.startTime.isBefore(rangeB.endTime) &&
266
          rangeB.endTime.isSameOrBefore(rangeA.endTime)
267
        ) {
268
          // Case 1: startA startB endB endA (outer overlap)
269
          overlaps.push({
8✔
270
            startTime: rangeB.startTime,
271
            endTime: rangeB.endTime,
272
            categories: [...new Set([rangeA.category, rangeB.category])]
273
          });
274
        } else if (
734✔
275
          rangeB.startTime.isSameOrBefore(rangeA.startTime) &&
750✔
276
          rangeA.startTime.isBefore(rangeA.endTime) &&
277
          rangeA.endTime.isSameOrBefore(rangeB.endTime)
278
        ) {
279
          // Case 2: startB startA endA endB (inner overlap)
280
          overlaps.push({
4✔
281
            startTime: rangeA.startTime,
282
            endTime: rangeA.endTime,
283
            categories: [...new Set([rangeA.category, rangeB.category])]
284
          });
285
        } else if (
730✔
286
          rangeA.startTime.isSameOrBefore(rangeB.startTime) &&
1,472✔
287
          rangeB.startTime.isBefore(rangeA.endTime) &&
288
          rangeA.endTime.isSameOrBefore(rangeB.endTime)
289
        ) {
290
          // Case 3: startA startB endA endB (leftward overlap)
291
          overlaps.push({
12✔
292
            startTime: rangeB.startTime,
293
            endTime: rangeA.endTime,
294
            categories: [...new Set([rangeA.category, rangeB.category])]
295
          });
296
        }
297
      });
298
    });
299
    // Do not display duplicate overlaps; an overlap is considered a duplicate
300
    // if it has the same start time, end time, *and* categories as an existing
301
    // overlap
302
    overlaps = uniqBy(overlaps, (overlap) => {
×
303
      return [
24✔
304
        overlap.startTime,
305
        overlap.endTime,
306
        overlap.categories.map((category) => category.name).join(';')
44✔
307
      ].join(',');
308
    });
309
    overlaps = this.sortTimeRanges(overlaps);
×
310

311
    return overlaps;
×
312
  }
313

314
  getLatestRange() {
315
    return maxBy(this.getAllTimeRanges(), (range) => range.endTime);
416✔
316
  }
317
}
318
// The textual time format used for all entered times, as well as displayed
319
// times
320
Log.prototype.timeFormat = 'h:mma';
×
321
// The number of minutes to round each time to
322
Log.prototype.minuteIncrement = 1;
×
323

324
export default Log;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc