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

caleb531 / workday-time-calculator / 24427670880

14 Apr 2026 11:13PM UTC coverage: 87.887% (-4.3%) from 92.158%
24427670880

push

github

caleb531
Get basic analytics UI functional.

The chart is still a bit rough around the edges stylistically, but it
works.

504 of 557 branches covered (90.48%)

Branch coverage included in aggregate %.

381 of 436 new or added lines in 6 files covered. (87.39%)

97 existing lines in 1 file now uncovered.

2137 of 2448 relevant lines covered (87.3%)

154.54 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

134
    return categories;
32✔
135
  }
32✔
136

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

158
  getAllTasks() {
20✔
159
    let tasks = [];
1,536✔
160
    this.categories.forEach((category) => {
1,536!
161
      tasks.push(...category.tasks);
776✔
162
    });
1,536✔
163
    return tasks;
1,536✔
164
  }
1,536✔
165

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

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

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

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

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

202
    return errors;
384✔
UNCOV
203
  }
×
204

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

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

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

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

UNCOV
242
    return gaps;
×
UNCOV
243
  }
×
244

UNCOV
245
  getOverlaps() {
✔
246
    let ranges = this.sortTimeRanges(this.getAllTimeRanges());
384✔
247

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

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

UNCOV
304
    return overlaps;
×
UNCOV
305
  }
×
306

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

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