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
+
+
+
+
+
+
+
+
Import
+
+
+
+
Views
+
+
+
+
+
Export
+
+
+
+
+
+
+
+
+
Records
+
+
+
+ Start |
+ End |
+ Duration |
+ Task |
+ Enabled? |
+
+
+
+
+
+
+
+
+
+
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);
+ }
+ }
+}