From 1134e9f7896bd824c9fe675f2f4ab6216fd8aab6 Mon Sep 17 00:00:00 2001 From: Fabian Scheidt Date: Mon, 8 Jun 2020 00:09:25 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + echarts-theme.js | 204 +++++++++++++++++++++++++++++++ index.html | 90 ++++++++++++++ model.js | 50 ++++++++ styles.css | 56 +++++++++ tim-evaluator.js | 309 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 710 insertions(+) create mode 100644 .gitignore create mode 100644 echarts-theme.js create mode 100644 index.html create mode 100644 model.js create mode 100644 styles.css create mode 100644 tim-evaluator.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/echarts-theme.js b/echarts-theme.js new file mode 100644 index 0000000..8ff95d4 --- /dev/null +++ b/echarts-theme.js @@ -0,0 +1,204 @@ +(function (root, factory) {if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['exports', 'echarts'], factory); +} else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') { + // CommonJS + factory(exports, require('echarts')); +} else { + // Browser globals + factory({}, root.echarts); +} +}(this, function (exports, echarts) { + var log = function (msg) { + if (typeof console !== 'undefined') { + console && console.error && console.error(msg); + } + }; + if (!echarts) { + log('ECharts is not Loaded'); + return; + } + + var colorPalette = [ + '#C1232B','#27727B','#FCCE10','#E87C25','#B5C334', + '#FE8463','#9BCA63','#FAD860','#F3A43B','#60C0DD', + '#D7504B','#C6E579','#F4E001','#F0805A','#26C0C0' + ]; + + var theme = { + + color: colorPalette, + + title: { + textStyle: { + fontWeight: 'normal', + color: '#27727B' + } + }, + + visualMap: { + color:['#C1232B','#FCCE10'] + }, + + toolbox: { + iconStyle: { + normal: { + borderColor: colorPalette[0] + } + } + }, + + tooltip: { + backgroundColor: 'rgba(50,50,50,0.5)', + axisPointer : { + type : 'line', + lineStyle : { + color: '#27727B', + type: 'dashed' + }, + crossStyle: { + color: '#27727B' + }, + shadowStyle : { + color: 'rgba(200,200,200,0.3)' + } + } + }, + + dataZoom: { + dataBackgroundColor: 'rgba(181,195,52,0.3)', + fillerColor: 'rgba(181,195,52,0.2)', + handleColor: '#27727B' + }, + + categoryAxis: { + axisLine: { + lineStyle: { + color: '#27727B' + } + }, + splitLine: { + show: false + } + }, + + valueAxis: { + axisLine: { + show: false + }, + splitArea : { + show: false + }, + splitLine: { + lineStyle: { + color: ['#ccc'], + type: 'dashed' + } + } + }, + + timeline: { + lineStyle: { + color: '#27727B' + }, + controlStyle: { + normal: { + color: '#27727B', + borderColor: '#27727B' + } + }, + symbol: 'emptyCircle', + symbolSize: 3 + }, + + line: { + itemStyle: { + normal: { + borderWidth:2, + borderColor:'#fff', + lineStyle: { + width: 3 + } + }, + emphasis: { + borderWidth:0 + } + }, + symbol: 'circle', + symbolSize: 3.5 + }, + + candlestick: { + itemStyle: { + normal: { + color: '#C1232B', + color0: '#B5C334', + lineStyle: { + width: 1, + color: '#C1232B', + color0: '#B5C334' + } + } + } + }, + + graph: { + color: colorPalette + }, + + map: { + label: { + normal: { + textStyle: { + color: '#C1232B' + } + }, + emphasis: { + textStyle: { + color: 'rgb(100,0,0)' + } + } + }, + itemStyle: { + normal: { + areaColor: '#ddd', + borderColor: '#eee' + }, + emphasis: { + areaColor: '#fe994e' + } + } + }, + + gauge: { + axisLine: { + lineStyle: { + color: [[0.2, '#B5C334'],[0.8, '#27727B'],[1, '#C1232B']] + } + }, + axisTick: { + splitNumber: 2, + length: 5, + lineStyle: { + color: '#fff' + } + }, + axisLabel: { + textStyle: { + color: '#fff' + } + }, + splitLine: { + length: '5%', + lineStyle: { + color: '#fff' + } + }, + title : { + offsetCenter: [0, -20] + } + } + }; + + echarts.registerTheme('infographic', theme); +})); diff --git a/index.html b/index.html new file mode 100644 index 0000000..c33bad6 --- /dev/null +++ b/index.html @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + Tim Evaluator + + +
+
+

Tim Evaluator

+

+ Visualizes "Tim" time tracker records + and allows to fill gaps between tracked times +

+
+
+
+
+
+
+

Import

+ +
+
+

Views

+ + +
+
+

Export

+ + +
+
+ +
+

Evaluation

+
+
+ +
+

Records

+ + + + + + + + + + + + +
StartEndDurationTaskEnabled?
+
+
+
+ + diff --git a/model.js b/model.js new file mode 100644 index 0000000..90902db --- /dev/null +++ b/model.js @@ -0,0 +1,50 @@ +class Group { + constructor(id, title, createdAt, tasks) { + this.id = id || null; + this.title = title || ''; + this.createdAt = createdAt ? new Date(createdAt) : new Date(); + this.tasks = tasks || []; + } +} + +class Task { + constructor(id, title, createdAt, records, group) { + this.id = id || null; + this.createdAt = createdAt ? new Date(createdAt) : new Date(); + this.title = title || ''; + this.records = records || []; + this.group = group || null; + } +} + +class Record { + constructor(task, start, end, disabled) { + this.task = task; + this.start = new Date(start); + this.end = new Date(end); + this.disabled = !!disabled; + } + + getStartStr() { + return moment(this.start).format('lll'); + } + + getEndStr() { + return moment(this.end).format('lll'); + } + + getDurationStr() { + const duration = Math.round((this.end.getTime() - this.start.getTime()) / 1000); + let hours = Math.floor(duration / 3600); + let minutes = Math.floor((duration - (hours * 3600)) / 60); + let seconds = duration - (hours * 3600) - (minutes * 60); + if (hours < 10) {hours = "0"+hours;} + if (minutes < 10) {minutes = "0"+minutes;} + if (seconds < 10) {seconds = "0"+seconds;} + return hours + ':' + minutes + ':' + seconds; + } + + getDurationHours() { + return (this.end.getTime() - this.start.getTime()) / 1000 / 60 / 60; + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..361ba07 --- /dev/null +++ b/styles.css @@ -0,0 +1,56 @@ +html, body { + margin: 0; + padding: 0; + background-color: #cccccc; + font-family: "Helvetica Neue", Arial, sans-serif; +} + +header { + margin-top: 40px; +} + +main { + margin-bottom: 40px; +} + +.wrapper { + max-width: 800px; + margin: 0 auto; + padding: 10px 40px; + background-color: #ffffff; +} + +.file { + display: flex; + margin-bottom: 60px; +} + +.file div { + flex-grow: 1; +} + +.evaluation #bar-chart { + height: 800px; +} + + +.records-list, .evaluation { + padding-bottom: 20px; +} + + +.records-list table { + width: 100%; +} + +.records-list table thead th { + text-align: left; +} + +.records-list table tbody tr.disabled { + opacity: 0.4; +} + +.records-list table tbody tr:hover { + background-color: #eeeeee; +} diff --git a/tim-evaluator.js b/tim-evaluator.js new file mode 100644 index 0000000..2a1a3e8 --- /dev/null +++ b/tim-evaluator.js @@ -0,0 +1,309 @@ +class TimEvaluator { + + constructor() { + this.groups = []; + this.tasks = []; + this.records = []; + this.fileInput = null; + this.gapDestination = null; + this.recordsTable = null; + this.barChart = null; + + this.tabs = []; + this.visibleTab = 0; + + this.minGapDuration = 5 * 60 * 1000; + } + + /** + * Attaches event handlers to initialize the app + */ + init() { + // Get DOM references + this.fileInput = document.querySelector('#file-input'); + this.gapDestination = document.querySelector('#gap-destination'); + this.recordsTable = document.querySelector('#records-table'); + this.barChart = echarts.init(document.querySelector('#bar-chart'), 'infographic'); + this.tabs = [ + document.querySelector('.evaluation'), + document.querySelector('.records-list') + ]; + + // Attach event listeners + this.fileInput.addEventListener('change', () => this.handleFileChange()); + document.querySelector('#show-evaluation-button').addEventListener('click', () => this.showTab(0)); + document.querySelector('#show-records-button').addEventListener('click', () => this.showTab(1)); + document.querySelector('#export-button').addEventListener('click', () => this.export()); + + // Initilize the view + this.updateView(); + } + + /** + * Reads a Tim export file and updates the view + */ + handleFileChange() { + // Empty tasks and records and update the view + this.groups = []; + this.tasks = []; + this.records = []; + this.updateView(); + + // Check if files are present. Abort if not. + const files = this.fileInput.files; + if (!files || !files.length) { + return; + } + + // Now read the first selected file + const fileReader = new FileReader(); + fileReader.onload = () => { + const result = JSON.parse(fileReader.result); + + // Read groups from the file and map them to objects + for (const groupId of Object.keys(result.groups)) { + const groupSrc = result.groups[groupId]; + const group = new Group(groupId, groupSrc.title, groupSrc.createdAt); + this.groups.push(group); + } + + // Read the tasks and records from the file and map them to objects + for (const taskId of Object.keys(result.tasks)) { + const taskSrc = result.tasks[taskId]; + const task = new Task(taskId, taskSrc.title, taskSrc.createdAt); + + task.records = taskSrc.records.map((record) => new Record(task, record.start, record.end)); + this.tasks.push(task); + this.records = this.records.concat(task.records); + + const groupId = result.nodes.find((node) => node.id === taskId).parent; + if (groupId) { + const group = this.groups.find((group) => group.id === groupId); + task.group = group; + group.tasks.push(task); + } + } + + // Sort records by start date + this.records.sort((a, b) => a.start.getTime() - b.start.getTime()); + + // Find gaps and add them as disabled records + const gaps = []; + let lastRecord = null; + for(const record of this.records) { + if (lastRecord && lastRecord.end.getTime() + this.minGapDuration < record.start.getTime()) { + gaps.push(new Record(null, lastRecord.end, record.start, true)); + } + lastRecord = record; + } + this.records = this.records.concat(gaps); + + // Sort by date once more + this.records.sort((a, b) => a.start.getTime() - b.start.getTime()); + + // Update the view + this.updateView(); + } + fileReader.readAsText(files[0]); + } + + /** + * Enables the tab with the provided index + * + * @param index + */ + showTab(index) { + this.visibleTab = index; + this.updateView(); + } + + /** + * Toggles the disabled property of a record + * + * @param record + */ + toggleDisabled(record) { + record.disabled = !record.disabled; + this.updateView(); + } + + /** + * Downloads a file that can be imported to Tim + */ + export() { + // Find enabled gaps + const gaps = this.records.filter((record) => !record.task && !record.disabled); + + // Find selected task + const taskId = this.gapDestination.value; + const task = this.tasks.find((task) => task.id === taskId); + + // Assign the task to the gaps + gaps.forEach((gap) => gap.task = task); + task.records = task.records.concat(gaps); + + // Now generate the resulting structure + const res = { + tasks: {}, + groups: {}, + nodes: [] + }; + + for (const group of this.groups) { + res.groups[group.id] = { + id: group.id, + title: group.title, + updatedAt: (new Date()).getTime(), + createdAt: group.createdAt.getTime() + }; + res.nodes.push({ id: group.id }); + } + + for (const task of this.tasks) { + res.tasks[task.id] = { + records: task.records.filter((record) => !record.disabled).map((record) => ({ + start: record.start.getTime(), + end: record.end.getTime() + })), + id: task.id, + title: task.title, + updatedAt: (new Date()).getTime(), + createdAt: task.createdAt.getTime() + } + const node = { id: task.id }; + if (task.group) { + node.parent = task.group.id; + } + res.nodes.push(node); + } + + const resJson = JSON.stringify(res, null, 2); + this.downloadString(resJson, 'text/json', 'export.json'); + + this.updateView(); + } + + /** + * Starts a file download of a text string + * + * @param text + * @param fileType + * @param fileName + */ + downloadString(text, fileType, fileName) { + const blob = new Blob([text], { type: fileType }); + + const a = document.createElement('a'); + a.download = fileName; + a.href = URL.createObjectURL(blob); + a.dataset.downloadurl = [fileType, a.download, a.href].join(':'); + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function() { URL.revokeObjectURL(a.href); }, 1500); + } + + + /** + * Updates the view + */ + updateView() { + this.updateChart(); + this.updateRecordsList(); + this.updateExportList(); + + // Update the visible tab + this.tabs.forEach((tab) => tab.style.display = 'none'); + this.tabs[this.visibleTab].style.display = 'block'; + } + + /** + * Updates the chart options based on the current tasks + */ + updateChart() { + // Initialize empty chart options + const option = { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' } + }, + legend: { + data: [] + }, + xAxis: { + type: 'value' + }, + yAxis: { + type: 'category', + data: [], + inverse: true + }, + series: [] + }; + + // Initialize legend and series for each tas + option.legend.data = this.tasks.map((task) => task.title); + option.series = this.tasks.map((task) => ({ + name: task.title, + type: 'bar', + stack: 'single', + data: [] + })); + + // Populate for each week + let lastWeek = ''; + for (const record of this.records) { + if (record.disabled || !record.task) { + continue; + } + + const recordWeek = 'KW ' + moment(record.start).format('YYYY-WW'); + if (recordWeek !== lastWeek) { + option.yAxis.data.push(recordWeek); + option.series.forEach((series) => series.data.push(0)); + lastWeek = recordWeek; + } + + const taskIndex = this.tasks.indexOf(record.task); + const series = option.series[taskIndex]; + series.data[series.data.length - 1] += record.getDurationHours(); + } + + // Now update the chart itself + this.barChart.setOption(option); + } + + /** + * Updates the list of time records + */ + updateRecordsList() { + const tableBody = this.recordsTable.querySelector('tbody'); + tableBody.innerHTML = ''; + this.records.forEach((record) => { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${ record.getStartStr() } + ${ record.getEndStr() } + ${ record.getDurationStr() } + ${ record.task ? record.task.title : '-' } + `; + tr.classList = record.disabled ? ['disabled'] : []; + tr.querySelector('td input').addEventListener('click', () => this.toggleDisabled(record)); + tableBody.appendChild(tr); + }); + } + + /** + * Updates the selection in the export section + */ + updateExportList() { + this.gapDestination.innerHTML = ''; + for (const task of this.tasks) { + const option = document.createElement('option'); + option.value = task.id; + option.innerText = task.title; + this.gapDestination.appendChild(option); + } + } +}