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

caleb531 / workday-time-calculator / 26660516246

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

push

github

caleb531
Drop Node 20 from CI; add Node 24

411 of 533 branches covered (77.11%)

Branch coverage included in aggregate %.

996 of 1222 relevant lines covered (81.51%)

139.42 hits per line

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

86.73
/scripts/components/analytics.jsx
1
import { BarChart, FixedScaleAxis } from 'chartist';
2
import clsx from 'clsx';
3
import m from 'mithril';
4
import moment from 'moment';
5
import AnalyticsWorker from '../analytics-worker.js?worker';
6
import { collectAnalytics } from '../models/analytics-collector.js';
7
import { formatDuration } from '../models/duration-formatter.js';
8
import CloseButtonComponent from './close-button.jsx';
9
import DateInputComponent from './date-input.jsx';
10
import DismissableOverlayComponent from './dismissable-overlay.jsx';
11
import LoadingComponent from './loading.jsx';
12

13
class AnalyticsComponent {
14
  oninit({ attrs: { preferences, onCloseAnalytics } }) {
15
    this.preferences = preferences;
16✔
16
    this.onCloseAnalytics = onCloseAnalytics;
16✔
17
    this.worker = typeof Worker !== 'undefined' ? new AnalyticsWorker() : null;
16!
18
    this.workerRequestId = 0;
16✔
19
    this.categories = [];
16✔
20
    this.isLoading = true;
16✔
21
    this.chart = null;
16✔
22
    this.chartRenderKey = null;
16✔
23
    this.chartBarPositions = [];
16✔
24
    this.chartYAxisLabelsElement = null;
16✔
25
    this.setDefaultDates();
16✔
26

27
    if (this.worker) {
16!
28
      this.worker.onmessage = (event) => {
16✔
29
        if (event.data.requestId !== this.workerRequestId) {
13✔
30
          return;
2✔
31
        }
32
        this.categories = event.data.categories;
11✔
33
        this.isLoading = false;
11✔
34
        m.redraw();
11✔
35
      };
36
      this.worker.onerror = () => {
16✔
37
        // If the analytics worker fails to load or crashes, keep the panel
38
        // usable by falling back to the same main-thread analytics path used
39
        // in browsers without Worker support.
40
        this.worker?.terminate();
×
41
        this.worker = null;
×
42
        this.fetchAnalytics();
×
43
      };
44
    }
45

46
    this.fetchAnalytics();
16✔
47
  }
48

49
  onremove() {
50
    this.destroyChart();
16✔
51
    if (this.worker) {
16!
52
      this.worker.terminate();
16✔
53
    }
54
  }
55

56
  setDefaultDates() {
57
    this.startDate = moment().subtract(7, 'days').format('YYYY-MM-DD');
16✔
58
    this.endDate = moment().format('YYYY-MM-DD');
16✔
59
  }
60

61
  destroyChart() {
62
    if (this.chart) {
43✔
63
      this.chart.detach();
5✔
64
      this.chart = null;
5✔
65
    }
66
    this.chartRenderKey = null;
43✔
67
    this.chartBarPositions = [];
43✔
68
    this.renderYAxisLabels([]);
43✔
69
  }
70

71
  get isDateRangeValid() {
72
    const startDate = moment(this.startDate, 'YYYY-MM-DD', true);
120✔
73
    const endDate = moment(this.endDate, 'YYYY-MM-DD', true);
120✔
74
    return (
120✔
75
      startDate.isValid() &&
360✔
76
      endDate.isValid() &&
77
      startDate.isSameOrBefore(endDate, 'day')
78
    );
79
  }
80

81
  get chartCategories() {
82
    return this.categories
354✔
83
      .slice()
84
      .reverse()
85
      .map((category) => {
86
        return {
467✔
87
          ...category,
88
          formattedDuration: formatDuration(category.totalMinutes)
89
        };
90
      });
91
  }
92

93
  get chartSummaryLabel() {
94
    if (!this.chartCategories.length) {
41✔
95
      return 'No analytics are available for this date range.';
35✔
96
    }
97
    return this.chartCategories
6✔
98
      .map((category) => {
99
        return `${category.name}: ${category.formattedDuration}`;
16✔
100
      })
101
      .join('; ');
102
  }
103

104
  get chartHeight() {
105
    return Math.max(240, this.chartCategories.length * 56);
51✔
106
  }
107

108
  get chartWidth() {
109
    return Math.max(320, 620 - this.yAxisOffset);
10✔
110
  }
111

112
  get chartMaxMinutes() {
113
    const maxMinutes = Math.max(
88✔
114
      0,
115
      ...this.chartCategories.map((category) => category.totalMinutes)
232✔
116
    );
117
    const tickSize = this.getXAxisTickSize(maxMinutes);
88✔
118
    return Math.max(tickSize, Math.ceil(maxMinutes / tickSize) * tickSize);
88✔
119
  }
120

121
  get yAxisOffset() {
122
    const longestLabelLength = Math.max(
56✔
123
      0,
124
      ...this.chartCategories.map((category) => category.name.length)
55✔
125
    );
126
    return Math.min(200, Math.max(110, longestLabelLength * 7));
56✔
127
  }
128

129
  getXAxisTickSize(maxMinutes) {
130
    if (maxMinutes <= 90) {
98✔
131
      return 15;
18✔
132
    } else if (maxMinutes <= 180) {
80!
133
      return 30;
×
134
    } else if (maxMinutes <= 480) {
80✔
135
      return 60;
40✔
136
    } else if (maxMinutes <= 960) {
40!
137
      return 120;
40✔
138
    }
139
    return 240;
×
140
  }
141

142
  getXAxisTicks() {
143
    const tickSize = this.getXAxisTickSize(this.chartMaxMinutes);
10✔
144
    const ticks = [];
10✔
145
    for (
10✔
146
      let minutes = 0;
10✔
147
      minutes <= this.chartMaxMinutes;
148
      minutes += tickSize
149
    ) {
150
      ticks.push(minutes);
58✔
151
    }
152
    return ticks;
10✔
153
  }
154

155
  fetchAnalytics() {
156
    this.destroyChart();
22✔
157
    if (!this.isDateRangeValid) {
22✔
158
      this.categories = [];
4✔
159
      this.isLoading = false;
4✔
160
      return;
4✔
161
    }
162

163
    this.isLoading = true;
18✔
164
    // Capture only the preference values the analytics model needs so worker
165
    // messages and fallback calls receive the same compact request payload.
166
    const preferences = {
18✔
167
      timeSystem: this.preferences.timeSystem,
168
      categorySortOrder: this.preferences.categorySortOrder
169
    };
170

171
    if (!this.worker) {
18!
172
      collectAnalytics({
×
173
        startDate: this.startDate,
174
        endDate: this.endDate,
175
        preferences: preferences
176
      }).then((categories) => {
177
        this.categories = categories;
×
178
        this.isLoading = false;
×
179
        m.redraw();
×
180
      });
181
      return;
×
182
    }
183

184
    this.workerRequestId += 1;
18✔
185
    this.worker.postMessage({
18✔
186
      requestId: this.workerRequestId,
187
      startDate: this.startDate,
188
      endDate: this.endDate,
189
      preferences: preferences
190
    });
191
  }
192

193
  handleDateInput(name, value) {
194
    if (this[name] === value) {
6!
195
      return;
×
196
    }
197

198
    this[name] = value;
6✔
199
    this.fetchAnalytics();
6✔
200
  }
201

202
  setYAxisLabelsElement(dom) {
203
    if (this.chartYAxisLabelsElement === dom) {
41✔
204
      return;
25✔
205
    }
206

207
    this.chartYAxisLabelsElement = dom;
16✔
208
    this.renderYAxisLabels(this.chartBarPositions);
16✔
209
  }
210

211
  renderYAxisLabels(barPositions) {
212
    if (!this.chartYAxisLabelsElement) {
60✔
213
      return;
16✔
214
    }
215

216
    this.chartYAxisLabelsElement.replaceChildren();
44✔
217
    barPositions.forEach((barPosition) => {
44✔
218
      const labelElement = document.createElement('div');
3✔
219
      labelElement.className = 'analytics-chart-y-label';
3✔
220
      labelElement.style.top = `${barPosition.y}px`;
3✔
221
      labelElement.textContent = barPosition.name;
3✔
222
      this.chartYAxisLabelsElement.appendChild(labelElement);
3✔
223
    });
224
  }
225

226
  renderChart(dom) {
227
    this.chartElement = dom;
41✔
228

229
    const shouldRenderChart =
230
      this.chartCategories.length && !this.isLoading && this.isDateRangeValid;
41✔
231
    if (!shouldRenderChart) {
41✔
232
      if (this.chart) {
36!
233
        this.destroyChart();
×
234
      }
235
      return;
36✔
236
    }
237

238
    const nextChartRenderKey = JSON.stringify({
5✔
239
      categories: this.chartCategories.map((category) => {
240
        return [
13✔
241
          category.name,
242
          category.totalMinutes,
243
          category.formattedDuration
244
        ];
245
      }),
246
      chartWidth: this.chartWidth,
247
      chartHeight: this.chartHeight,
248
      chartMaxMinutes: this.chartMaxMinutes,
249
      yAxisOffset: this.yAxisOffset,
250
      xAxisTicks: this.getXAxisTicks()
251
    });
252

253
    if (this.chart && this.chartRenderKey === nextChartRenderKey) {
5!
254
      return;
×
255
    }
256

257
    this.destroyChart();
5✔
258
    this.chartRenderKey = nextChartRenderKey;
5✔
259

260
    const barPositions = [];
5✔
261

262
    this.chart = new BarChart(
5✔
263
      dom,
264
      {
265
        labels: this.chartCategories.map((category) => category.name),
13✔
266
        series: this.chartCategories.map((category) => category.totalMinutes)
13✔
267
      },
268
      {
269
        distributeSeries: true,
270
        horizontalBars: true,
271
        width: `${this.chartWidth}px`,
272
        height: `${this.chartHeight}px`,
273
        low: 0,
274
        high: this.chartMaxMinutes,
275
        chartPadding: {
276
          top: 10,
277
          right: 56,
278
          bottom: 20,
279
          left: 0
280
        },
281
        axisX: {
282
          type: FixedScaleAxis,
283
          offset: 30,
284
          ticks: this.getXAxisTicks(),
285
          labelInterpolationFnc: (value) => formatDuration(value)
6✔
286
        },
287
        axisY: {
288
          offset: 0,
289
          showLabel: false,
290
          showGrid: false
291
        }
292
      }
293
    );
294

295
    this.chart.on('draw', (event) => {
5✔
296
      if (event.type !== 'bar') {
15✔
297
        return;
12✔
298
      }
299

300
      const categoryIndex =
301
        typeof event.seriesIndex === 'number' ? event.seriesIndex : event.index;
3!
302
      const category = this.chartCategories[categoryIndex];
15✔
303
      barPositions[categoryIndex] = {
15✔
304
        name: category.name,
305
        y: event.y1
306
      };
307
      event.group
15✔
308
        .elem(
309
          'text',
310
          {
311
            x: Math.max(event.x1, event.x2) + 6,
312
            y: event.y1,
313
            dy: '0.35em',
314
            'text-anchor': 'start'
315
          },
316
          'analytics-chart-bar-label'
317
        )
318
        .text(category.formattedDuration);
319
    });
320

321
    this.chart.on('created', () => {
5✔
322
      this.chartBarPositions = barPositions.filter(Boolean);
1✔
323
      this.renderYAxisLabels(this.chartBarPositions);
1✔
324
    });
325
  }
326

327
  view() {
328
    return (
41✔
329
      <div className={clsx('app-analytics', { 'app-analytics-open': true })}>
330
        <DismissableOverlayComponent
331
          aria-labelledby="app-analytics-close-control"
332
          onDismiss={() => this.onCloseAnalytics()}
×
333
        />
334

335
        <div
336
          className="panel app-analytics-panel"
337
          data-testid="analytics-panel"
338
        >
339
          <CloseButtonComponent
340
            id="app-analytics-close-control"
341
            aria-label="Close Analytics"
342
            onClose={() => this.onCloseAnalytics()}
×
343
          />
344

345
          <h2 className="app-analytics-heading">Analytics</h2>
346

347
          <div className="analytics-range-controls">
348
            <DateInputComponent
349
              aria-label="Start Date"
350
              value={this.startDate}
351
              onChange={(value) => this.handleDateInput('startDate', value)}
4✔
352
            />
353
            <span className="analytics-range-separator">thru</span>
354
            <DateInputComponent
355
              aria-label="End Date"
356
              value={this.endDate}
357
              onChange={(value) => this.handleDateInput('endDate', value)}
2✔
358
            />
359
          </div>
360

361
          <div className="analytics-chart-area">
362
            <div className="analytics-chart-axis-title">Total Time</div>
363
            {this.isLoading ? (
41✔
364
              <LoadingComponent className="analytics-loading" />
365
            ) : null}
366
            {!this.isDateRangeValid ? (
41✔
367
              <p className="analytics-empty-state">
368
                Start date must be on or before end date.
369
              </p>
370
            ) : null}
371
            {this.isDateRangeValid &&
125✔
372
            !this.isLoading &&
373
            !this.chartCategories.length ? (
374
              <p className="analytics-empty-state">
375
                No analytics are available for this date range.
376
              </p>
377
            ) : null}
378
            <div
379
              className={clsx('analytics-chart-layout', {
380
                'analytics-chart-hidden':
381
                  this.isLoading ||
57✔
382
                  !this.isDateRangeValid ||
383
                  !this.chartCategories.length
384
              })}
385
            >
386
              <div
387
                className="analytics-chart-y-labels"
388
                style={`width: ${this.yAxisOffset}px; height: ${this.chartHeight}px;`}
389
                oncreate={({ dom }) => this.setYAxisLabelsElement(dom)}
16✔
390
                onupdate={({ dom }) => this.setYAxisLabelsElement(dom)}
25✔
391
              />
392
              <div
393
                className="analytics-chart-canvas"
394
                aria-label={this.chartSummaryLabel}
395
                data-testid="analytics-chart"
396
                oncreate={({ dom }) => this.renderChart(dom)}
16✔
397
                onupdate={({ dom }) => this.renderChart(dom)}
25✔
398
              />
399
            </div>
400
            <ul
401
              className="analytics-chart-summary"
402
              data-testid="analytics-chart-summary"
403
            >
404
              {this.chartCategories.map((category) => {
405
                return (
16✔
406
                  <li>
407
                    {category.name}: {category.formattedDuration}
408
                  </li>
409
                );
410
              })}
411
            </ul>
412
          </div>
413
        </div>
414
      </div>
415
    );
416
  }
417
}
418

419
export default AnalyticsComponent;
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