• 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

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

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

24
    if (this.worker) {
6✔
25
      this.worker.onmessage = (event) => {
6✔
26
        if (event.data.requestId !== this.workerRequestId) {
8✔
27
          return;
2✔
28
        }
2✔
29
        this.categories = event.data.categories;
6✔
30
        this.isLoading = false;
6✔
31
        m.redraw();
6✔
32
      };
8✔
33
    }
6✔
34

35
    this.fetchAnalytics();
6✔
36
  }
6✔
37

38
  onremove() {
2✔
39
    this.destroyChart();
6✔
40
    if (this.worker) {
6✔
41
      this.worker.terminate();
6✔
42
    }
6✔
43
  }
6✔
44

45
  setDefaultDates() {
2✔
46
    this.startDate = moment().subtract(7, 'days').format('YYYY-MM-DD');
6✔
47
    this.endDate = moment().format('YYYY-MM-DD');
6✔
48
  }
6✔
49

50
  destroyChart() {
2✔
51
    if (this.chart) {
27✔
52
      this.chart.detach();
6✔
53
      this.chart = null;
6✔
54
    }
6✔
55
    this.renderYAxisLabels([]);
27✔
56
  }
27✔
57

58
  get isDateRangeValid() {
2✔
59
    const startDate = moment(this.startDate, 'YYYY-MM-DD', true);
46✔
60
    const endDate = moment(this.endDate, 'YYYY-MM-DD', true);
46✔
61
    return (
46✔
62
      startDate.isValid() &&
46✔
63
      endDate.isValid() &&
46✔
64
      startDate.isSameOrBefore(endDate, 'day')
46✔
65
    );
66
  }
46✔
67

68
  get chartCategories() {
2✔
69
    return this.categories.slice().reverse().map((category) => {
159✔
70
      return {
292✔
71
        ...category,
292✔
72
        formattedDuration: formatDuration(category.totalMinutes)
292✔
73
      };
292✔
74
    });
159✔
75
  }
159✔
76

77
  get chartSummaryLabel() {
2✔
78
    if (!this.chartCategories.length) {
13✔
79
      return 'No analytics are available for this date range.';
7✔
80
    }
7✔
81
    return this.chartCategories
6✔
82
      .map((category) => {
6✔
83
        return `${category.name}: ${category.formattedDuration}`;
14✔
84
      })
6✔
85
      .join('; ');
6✔
86
  }
13✔
87

88
  get chartHeight() {
2✔
89
    return Math.max(240, this.chartCategories.length * 56);
19✔
90
  }
19✔
91

92
  get chartWidth() {
2✔
93
    return Math.max(320, 620 - this.yAxisOffset);
6✔
94
  }
6✔
95

96
  get chartMaxMinutes() {
2✔
97
    const maxMinutes = Math.max(
52✔
98
      0,
52✔
99
      ...this.chartCategories.map((category) => category.totalMinutes)
52✔
100
    );
52✔
101
    const tickSize = this.getXAxisTickSize(maxMinutes);
52✔
102
    return Math.max(tickSize, Math.ceil(maxMinutes / tickSize) * tickSize);
52✔
103
  }
52✔
104

105
  get yAxisOffset() {
2✔
106
    const longestLabelLength = Math.max(
19✔
107
      0,
19✔
108
      ...this.chartCategories.map((category) => category.name.length)
19✔
109
    );
19✔
110
    return Math.min(200, Math.max(110, longestLabelLength * 7));
19✔
111
  }
19✔
112

113
  getXAxisTickSize(maxMinutes) {
2✔
114
    if (maxMinutes <= 90) {
58✔
115
      return 15;
18✔
116
    } else if (maxMinutes <= 180) {
58!
NEW
117
      return 30;
×
118
    } else if (maxMinutes <= 480) {
40✔
119
      return 60;
20✔
120
    } else if (maxMinutes <= 960) {
20✔
121
      return 120;
20✔
122
    }
20!
NEW
123
    return 240;
×
124
  }
58✔
125

126
  getXAxisTicks() {
2✔
127
    const tickSize = this.getXAxisTickSize(this.chartMaxMinutes);
6✔
128
    const ticks = [];
6✔
129
    for (let minutes = 0; minutes <= this.chartMaxMinutes; minutes += tickSize) {
6✔
130
      ticks.push(minutes);
34✔
131
    }
34✔
132
    return ticks;
6✔
133
  }
6✔
134

135
  fetchAnalytics() {
2✔
136
    this.destroyChart();
8✔
137
    if (!this.isDateRangeValid) {
8!
NEW
138
      this.categories = [];
×
NEW
139
      this.isLoading = false;
×
NEW
140
      return;
×
NEW
141
    }
×
142

143
    this.isLoading = true;
8✔
144
    const preferences = {
8✔
145
      timeSystem: this.preferences.timeSystem,
8✔
146
      categorySortOrder: this.preferences.categorySortOrder
8✔
147
    };
8✔
148

149
    if (!this.worker) {
8!
NEW
150
      collectAnalytics({
×
NEW
151
        startDate: this.startDate,
×
NEW
152
        endDate: this.endDate,
×
NEW
153
        preferences: preferences
×
NEW
154
      }).then((categories) => {
×
NEW
155
        this.categories = categories;
×
NEW
156
        this.isLoading = false;
×
NEW
157
        m.redraw();
×
NEW
158
      });
×
NEW
159
      return;
×
NEW
160
    }
×
161

162
    this.workerRequestId += 1;
8✔
163
    this.worker.postMessage({
8✔
164
      requestId: this.workerRequestId,
8✔
165
      startDate: this.startDate,
8✔
166
      endDate: this.endDate,
8✔
167
      preferences: preferences
8✔
168
    });
8✔
169
  }
8✔
170

171
  handleDateInput(event) {
2✔
172
    this[event.target.name] = event.target.value;
2✔
173
    this.fetchAnalytics();
2✔
174
  }
2✔
175

176
  setYAxisLabelsElement(dom) {
2✔
177
    this.chartYAxisLabelsElement = dom;
13✔
178
    this.renderYAxisLabels([]);
13✔
179
  }
13✔
180

181
  renderYAxisLabels(barPositions) {
2✔
182
    if (!this.chartYAxisLabelsElement) {
40✔
183
      return;
6✔
184
    }
6✔
185

186
    this.chartYAxisLabelsElement.replaceChildren();
34✔
187
    barPositions.forEach((barPosition) => {
34✔
NEW
188
      const labelElement = document.createElement('div');
×
NEW
189
      labelElement.className = 'analytics-chart-y-label';
×
NEW
190
      labelElement.style.top = `${barPosition.y}px`;
×
NEW
191
      labelElement.textContent = barPosition.name;
×
NEW
192
      this.chartYAxisLabelsElement.appendChild(labelElement);
×
193
    });
34✔
194
  }
40✔
195

196
  renderChart(dom) {
2✔
197
    this.chartElement = dom;
13✔
198
    this.destroyChart();
13✔
199

200
    if (!this.chartCategories.length || this.isLoading || !this.isDateRangeValid) {
13✔
201
      return;
7✔
202
    }
7✔
203

204
    const barPositions = [];
6✔
205

206
    this.chart = new BarChart(
6✔
207
      dom,
6✔
208
      {
6✔
209
        labels: this.chartCategories.map((category) => category.name),
6✔
210
        series: this.chartCategories.map((category) => category.totalMinutes)
6✔
211
      },
6✔
212
      {
6✔
213
        distributeSeries: true,
6✔
214
        horizontalBars: true,
6✔
215
        width: `${this.chartWidth}px`,
6✔
216
        height: `${this.chartHeight}px`,
6✔
217
        low: 0,
6✔
218
        high: this.chartMaxMinutes,
6✔
219
        chartPadding: {
6✔
220
          top: 10,
6✔
221
          right: 12,
6✔
222
          bottom: 20,
6✔
223
          left: 0
6✔
224
        },
6✔
225
        axisX: {
6✔
226
          type: FixedScaleAxis,
6✔
227
          offset: 30,
6✔
228
          ticks: this.getXAxisTicks(),
6✔
229
          labelInterpolationFnc: (value) => formatDuration(value)
6✔
230
        },
6✔
231
        axisY: {
6✔
232
          offset: 0,
6✔
233
          showLabel: false,
6✔
234
          showGrid: false
6✔
235
        }
6✔
236
      }
6✔
237
    );
6✔
238

239
    this.chart.on('draw', (event) => {
6✔
NEW
240
      if (event.type !== 'bar') {
×
NEW
241
        return;
×
NEW
242
      }
×
243

NEW
244
      const categoryIndex =
×
NEW
245
        typeof event.seriesIndex === 'number' ? event.seriesIndex : event.index;
×
NEW
246
      const category = this.chartCategories[categoryIndex];
×
NEW
247
      barPositions[categoryIndex] = {
×
NEW
248
        name: category.name,
×
NEW
249
        y: event.y1
×
NEW
250
      };
×
NEW
251
      event.group
×
NEW
252
        .elem(
×
NEW
253
          'text',
×
NEW
254
          {
×
NEW
255
            x: Math.max(event.x1, event.x2) - 8,
×
NEW
256
            y: event.y1,
×
NEW
257
            dy: '0.35em',
×
NEW
258
            'text-anchor': 'end'
×
NEW
259
          },
×
NEW
260
          'analytics-chart-bar-label'
×
NEW
261
        )
×
NEW
262
        .text(category.formattedDuration);
×
263
    });
6✔
264

265
    this.chart.on('created', () => {
6✔
NEW
266
      this.renderYAxisLabels(barPositions.filter(Boolean));
×
267
    });
6✔
268
  }
13✔
269

270
  view() {
2✔
271
    return (
13✔
272
      <div className={clsx('app-analytics', { 'app-analytics-open': true })}>
13✔
273
        <DismissableOverlayComponent
13✔
274
          aria-labelledby="app-analytics-close-control"
13✔
275
          onDismiss={() => this.onCloseAnalytics()}
13✔
276
        />
13✔
277

278
        <div className="panel app-analytics-panel" data-testid="analytics-panel">
13✔
279
          <CloseButtonComponent
13✔
280
            id="app-analytics-close-control"
13✔
281
            aria-label="Close Analytics"
13✔
282
            onClose={() => this.onCloseAnalytics()}
13✔
283
          />
13✔
284

285
          <h2 className="app-analytics-heading">Analytics</h2>
13✔
286

287
          <div className="analytics-range-controls">
13✔
288
            <input
13✔
289
              aria-label="Start Date"
13✔
290
              className="analytics-date-input"
13✔
291
              name="startDate"
13✔
292
              type="date"
13✔
293
              value={this.startDate}
13✔
294
              oninput={(event) => this.handleDateInput(event)}
13✔
295
            />
13✔
296
            <span className="analytics-range-separator">thru</span>
13✔
297
            <input
13✔
298
              aria-label="End Date"
13✔
299
              className="analytics-date-input"
13✔
300
              name="endDate"
13✔
301
              type="date"
13✔
302
              value={this.endDate}
13✔
303
              oninput={(event) => this.handleDateInput(event)}
13✔
304
            />
13✔
305
          </div>
13✔
306

307
          <div className="analytics-chart-area">
13✔
308
            <div className="analytics-chart-axis-title">Total Time</div>
13✔
309
            {this.isLoading ? (
13✔
310
              <LoadingComponent className="analytics-loading" />
7✔
311
            ) : null}
6✔
312
            {!this.isDateRangeValid ? (
13!
NEW
313
              <p className="analytics-empty-state">
×
314
                Start date must be on or before end date.
NEW
315
              </p>
×
316
            ) : null}
13✔
317
            {this.isDateRangeValid && !this.isLoading && !this.chartCategories.length ? (
13!
NEW
318
              <p className="analytics-empty-state">
×
319
                No analytics are available for this date range.
NEW
320
              </p>
×
321
            ) : null}
13✔
322
            <div
13✔
323
              className={clsx('analytics-chart-layout', {
13✔
324
                'analytics-chart-hidden': this.isLoading || !this.isDateRangeValid || !this.chartCategories.length
13✔
325
              })}
13✔
326
            >
327
              <div
13✔
328
                className="analytics-chart-y-labels"
13✔
329
                style={`width: ${this.yAxisOffset}px; height: ${this.chartHeight}px;`}
13✔
330
                oncreate={({ dom }) => this.setYAxisLabelsElement(dom)}
13✔
331
                onupdate={({ dom }) => this.setYAxisLabelsElement(dom)}
13✔
332
              />
13✔
333
              <div
13✔
334
                className="analytics-chart-canvas"
13✔
335
                aria-label={this.chartSummaryLabel}
13✔
336
                data-testid="analytics-chart"
13✔
337
                oncreate={({ dom }) => this.renderChart(dom)}
13✔
338
                onupdate={({ dom }) => this.renderChart(dom)}
13✔
339
              />
13✔
340
            </div>
13✔
341
            <ul className="analytics-chart-summary" data-testid="analytics-chart-summary">
13✔
342
              {this.chartCategories.map((category) => {
13✔
343
                return (
14✔
344
                  <li>
14✔
345
                    {category.name}: {category.formattedDuration}
14✔
346
                  </li>
14✔
347
                );
348
              })}
13✔
349
            </ul>
13✔
350
          </div>
13✔
351
        </div>
13✔
352
      </div>
13✔
353
    );
354
  }
13✔
355
}
2✔
356

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