Yes, this would be a great widget to have available. I managed to get at least 2 of them working years ago (one with Google charts, and the other with ChartJS, I think). Here is what a simple timeline could look like for monitoring lights in different rooms:
I found some old example code, but have not tried it since 0.20.x -- so buyer beware!
[{"id":"8da01ad2.268c08","type":"ui_template","z":"5f33da3f.ce2ef4","group":"3b6fc506.b120ca","name":"chartjs timeline","order":0,"width":"16","height":"9","format":"<script>\nvar max_duration = .01; // number of hours of history to show\n\nvar labels = [];\nvar datasets = [];\nvar colorArray = ['#FF6633','#4D4D4D','#5DA5DA','#FAA43A','#60BD68','#F17CB0','#B2912F','#B276B2','#DECF3F','#F15854'];\n\n\nvar ctx = document.getElementById(\"canvas\").getContext(\"2d\");\n\n// window.timeline = new Chart(ctx, {\nvar chart = new Chart(ctx, {\n type: 'timeLine',\n options: {\n responsive: true,\n colorFunction: function(data){\n // data is the dataset event point.\n // The first and second entries are the start/stop date\n //The third entry specifies the color to use\n return (typeof data[2] === 'undefined' ? 'black' : data[2]);\n }\n },\n data: {\n labels: labels,\n datasets: datasets\n }\n \n // data: {\n // labels: [\"Joe\", \"Bob\", \"Jim\", \"Alice\"],\n // datasets: [{\n // data: [\n // [new Date('2018-04-11'), new Date('2018-04-12')],\n // [new Date('2018-04-14'), new Date('2018-04-18')],\n // [new Date('2018-04-25'), new Date('2018-04-27')]\n // ]\n // }, {\n // data: [\n // [getDate(1), getDate(3)],\n // [getDate(4), getDate(6)],\n // [getDate(7), getDate(9)]\n // ]\n // }, {\n // data: [\n // [getDate(3), getDate(5)],\n // [getDate(6), getDate(9)]\n // ]\n // }, {\n // data: [\n // [getDate(3), getDate(5)],\n // [getDate(6), getDate(20)]\n // ]\n // }]\n // }\n});\n \n(function(scope){\n scope.$watch('msg', function(msg) {\n if(typeof(msg) !== \"object\") return;\n \n if(typeof(msg.cache) === \"object\"){\n console.log(\"Read cache\");\n \n // It blows up here!\n labels = [];\n datasets = [];\n \n // for(var i in msg.cache.labels){\n // scope.labels[i] = msg.cache.labels[i];\n // }\n // scope.labels = msg.cache.labels;\n \n // console.log(scope.labels);\n // console.log(msg.cache.datasets);\n \n \n // scope.labels = [\"a\"];\n // scope.datasets = [{data: [\n // [new Date('2018-04-11'), new Date('2018-04-12')],\n // [new Date('2018-04-14'), new Date('2018-04-18')],\n // [new Date('2018-04-25'), new Date('2018-04-27')]\n // ]}];\n // scope.datasets = msg.cache.datasets;\n // return null;\n }\n\n if(typeof(msg.topic) === \"string\" && typeof(msg.payload) === \"boolean\"){\n /* Begin history tracking */\n // Doesn't exist so init the object\n if(labels.indexOf(msg.topic) == -1){\n labels.push(msg.topic);\n \n datasets.push({\n label: msg.topic,\n current: false,\n timestamp: new Date(),\n data: []\n });\n }\n \n var thisIndex = labels.indexOf(msg.topic);\n \n // The state has changed\n if(msg.payload != datasets[thisIndex].current){\n datasets[thisIndex].current = msg.payload;\n datasets[thisIndex].timestamp = new Date();\n \n // Only add a new event if the state switched from false to true\n if(msg.payload === true){\n // add a new start/stop event to the topics history\n var event = [new Date(), new Date(), colorArray[thisIndex]];\n datasets[thisIndex].data.push(event);\n }\n }\n }\n \n \n angular.forEach(datasets, function(topic){\n if(topic.current === true && topic.data.length){\n // Update the event stop time to the current time\n topic.data[topic.data.length - 1][1] = new Date();\n }\n \n\n angular.forEach(topic.data, function(event, index, object){\n // cleanup old history (anything with an ending time more than max_duration hours old)\n if(new Date() - event[1] > max_duration * 3600000){\n // Delete the event\n object.splice(index,1);\n }\n });\n });\n \n /* END History tracking */\n \n // Force the timeline to redraw by adding an event to the end of the first bar\n // if(scope.datasets.length){\n // scope.datasets[0].data[0].push([new Date(), new Date()]);\n // }\n\nconsole.log(chart.config.data.labels);\n\n //redraw\n chart.update();\n \n // if(scope.datasets.length){\n // scope.datasets[0].data[0].pop();\n // }\n \n // This triggers a never ending loop for some reason\n var metas = [];\n for(var i in datasets){\n metas[i] = datasets[i]._meta;\n delete datasets[i]._meta;\n }\n \n // Attempting to make chart persistence between deploys\n scope.send({cache: chart.config.data});\n // scope.send({payload: [\"one\"]});\n \n // put the meta property back in\n for(var i in datasets){\n datasets[i]._meta = metas[i];\n }\n\n\n });\n})(scope);\n\n\n</script>\n\n<div style=\"width: 100%;\">\n <canvas id=\"canvas\"></canvas>\n</div>","storeOutMessages":false,"fwdInMessages":false,"templateScope":"local","x":520,"y":120,"wires":[["190d9e6d.358372"]]},{"id":"9d4bd2dd.2039","type":"inject","z":"5f33da3f.ce2ef4","name":"Update Chart","topic":"","payload":"","payloadType":"date","repeat":"10","crontab":"","once":false,"x":180,"y":80,"wires":[["8da01ad2.268c08"]]},{"id":"7a58d2cb.24da1c","type":"inject","z":"5f33da3f.ce2ef4","name":"Kitchen On","topic":"Kitchen","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":240,"wires":[["8da01ad2.268c08"]]},{"id":"6bb6f899.9fa418","type":"inject","z":"5f33da3f.ce2ef4","name":"Bathroom On","topic":"Bathroom","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":170,"y":280,"wires":[["8da01ad2.268c08"]]},{"id":"ee8743c2.c6c33","type":"inject","z":"5f33da3f.ce2ef4","name":"Living On","topic":"Living","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":320,"wires":[["8da01ad2.268c08"]]},{"id":"a587d9bb.d0f428","type":"inject","z":"5f33da3f.ce2ef4","name":"Basement On","topic":"Basement","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":170,"y":360,"wires":[["8da01ad2.268c08"]]},{"id":"8fb689c5.e27a08","type":"inject","z":"5f33da3f.ce2ef4","name":"Kitchen Off","topic":"Kitchen","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":480,"wires":[["8da01ad2.268c08"]]},{"id":"4494cb0e.a4b6e4","type":"inject","z":"5f33da3f.ce2ef4","name":"Bathroom Off","topic":"Bathroom","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":170,"y":520,"wires":[["8da01ad2.268c08"]]},{"id":"9c059944.1524d8","type":"inject","z":"5f33da3f.ce2ef4","name":"Living Off","topic":"Living","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":560,"wires":[["8da01ad2.268c08"]]},{"id":"3aa733d5.c27a7c","type":"inject","z":"5f33da3f.ce2ef4","name":"Basement Off","topic":"Basement","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":170,"y":600,"wires":[["8da01ad2.268c08"]]},{"id":"2f33255.08169da","type":"inject","z":"5f33da3f.ce2ef4","name":"Garage On","topic":"Garage","payload":"true","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":200,"wires":[["8da01ad2.268c08"]]},{"id":"41f2bb30.3fb914","type":"inject","z":"5f33da3f.ce2ef4","name":"Garage Off","topic":"Garage","payload":"false","payloadType":"json","repeat":"","crontab":"","once":false,"x":160,"y":440,"wires":[["8da01ad2.268c08"]]},{"id":"190d9e6d.358372","type":"debug","z":"5f33da3f.ce2ef4","name":"debug msg object","active":false,"console":"false","complete":"true","x":770,"y":80,"wires":[]},{"id":"3118460e.fa9faa","type":"ui_template","z":"5f33da3f.ce2ef4","group":"3b6fc506.b120ca","name":"ChartJS Timeline Library","order":0,"width":0,"height":0,"format":"<script>\nconst helpers = Chart.helpers;\nconst isArray = helpers.isArray;\n\nvar time = {\n\t\tunits: [{\n\t\t\tname: 'millisecond',\n\t\t\tsteps: [1, 2, 5, 10, 20, 50, 100, 250, 500]\n\t\t}, {\n\t\t\tname: 'second',\n\t\t\tsteps: [1, 2, 5, 10, 30]\n\t\t}, {\n\t\t\tname: 'minute',\n\t\t\tsteps: [1, 2, 5, 10, 30]\n\t\t}, {\n\t\t\tname: 'hour',\n\t\t\tsteps: [1, 2, 3, 6, 12]\n\t\t}, {\n\t\t\tname: 'day',\n\t\t\tsteps: [1, 2, 3, 5]\n\t\t}, {\n\t\t\tname: 'week',\n\t\t\tmaxStep: 4\n\t\t}, {\n\t\t\tname: 'month',\n\t\t\tmaxStep: 3\n\t\t}, {\n\t\t\tname: 'quarter',\n\t\t\tmaxStep: 4\n\t\t}, {\n\t\t\tname: 'year',\n\t\t\tmaxStep: false\n\t\t}]\n};\n\nvar myConfig = {\n myTime : {\n redoLabels: false\n },\n position: 'bottom',\n\n time: {\n parser: false, // false == a pattern string from http://momentjs.com/docs/#/parsing/string-format/ or a custom callback that converts its argument to a moment\n format: false, // DEPRECATED false == date objects, moment object, callback or a pattern string from http://momentjs.com/docs/#/parsing/string-format/\n unit: false, // false == automatic or override with week, month, year, etc.\n round: false, // none, or override with week, month, year, etc.\n displayFormat: false, // DEPRECATED\n isoWeekday: false, // override week start day - see http://momentjs.com/docs/#/get-set/iso-weekday/\n minUnit: 'millisecond',\n\n // defaults to unit's corresponding unitFormat below or override using pattern string from http://momentjs.com/docs/#/displaying/format/\n displayFormats: {\n millisecond: 'h:mm:ss.SSS a', // 11:20:01.123 AM,\n second: 'h:mm:ss a', // 11:20:01 AM\n minute: 'h:mm:ss a', // 11:20:01 AM\n quarter: '[Q]Q - YYYY', // Q3\n year: 'YYYY', // 2015 \n hour: 'MMM D, hA', // Sept 4, 5PM\n day: 'll', // Sep 4 2015\n week: 'll', // Week 46, or maybe \"[W]WW - YYYY\" ?\n month: 'MMM YYYY', // Sept 2015\n }\n },\n ticks: {\n autoSkip: false\n }\n};\n\n\nvar myTimeScale = Chart.scaleService.getScaleConstructor('time').extend({\n\n determineDataLimits: function() {\n var me = this;\n me.labelMoments = [];\n\n // We parse all date labels here, for each entry we parse its initial and end date\n var scaleLabelMoments = [];\n if (me.chart.data.datasets && me.chart.data.datasets.length > 0) {\n helpers.each(me.chart.data.datasets, function(datasets) {\n var data = datasets.data;\n var length = data.length;\n for (var i = 0; i < length; i++) {\n // We consider 0 to have initial date\n var initialLabelMoment = me.parseTime(data[i][0]);\n // we consider 1 to have end date\n // TODO maybe add a check to see which one is bigger, but right now i don't know the\n // TODO implications off that check\n var finalLabelMoment = me.parseTime(data[i][1]);\n if (initialLabelMoment.isValid()) {\n if (me.options.time.round) {\n initialLabelMoment.startOf(me.options.time.round);\n }\n scaleLabelMoments.push(initialLabelMoment);\n }\n if (finalLabelMoment.isValid()) {\n if (me.options.time.round) {\n finalLabelMoment.startOf(me.options.time.round);\n }\n scaleLabelMoments.push(finalLabelMoment);\n }\n }\n }, me);\n\n me.firstTick = moment.min.call(me, scaleLabelMoments);\n me.lastTick = moment.max.call(me, scaleLabelMoments);\n } else {\n me.firstTick = null;\n me.lastTick = null;\n }\n\n // In this case label moments are the same as scale moments because this chart only supports\n // dates as data and not labels like normal time scale. We are doing this to keep\n // coordination between parent(TimeScale) calls\n me.labelMoments.push(scaleLabelMoments);\n\n // Set these after we've done all the data\n if (me.options.time.min) {\n me.firstTick = me.parseTime(me.options.time.min);\n }\n\n if (me.options.time.max) {\n me.lastTick = me.parseTime(me.options.time.max);\n }\n\n // We will modify these, so clone for later\n me.firstTick = (me.firstTick || moment()).clone();\n me.lastTick = (me.lastTick || moment()).clone();\n },\n buildLabelDiffs: function() {\n var me = this;\n me.labelDiffs = [];\n var scaleLabelDiffs = [];\n // Parse common labels once\n if (me.chart.data.datasets && me.chart.data.datasets.length > 0) {\n helpers.each(me.chart.data.datasets, function(datasets, datasetIndex) {\n var data = datasets.data;\n var length = data.length;\n for (var i = 0; i < length; i++) {\n // We consider 0 to have initial date\n var initialLabelMoment = me.parseTime(data[i][0]);\n // we consider 1 to have end date\n // TODO maybe add a check to see which one is bigger, but right now i don't know the\n // TODO implications off that check\n var finalLabelMoment = me.parseTime(data[i][1]);\n var diff;\n if (initialLabelMoment.isValid()) {\n if (me.options.time.round) {\n diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, false);\n }\n else {\n if (me.isInTicks(initialLabelMoment, me.tickUnit))\n // No floor needed since we are one of the ticks\n diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, false);\n else\n diff = initialLabelMoment.diff(me.firstTick, me.tickUnit, true);\n }\n scaleLabelDiffs.push(diff);\n }\n if (finalLabelMoment.isValid()) {\n if (me.options.time.round) {\n // Moment doesn't round on diff anymore\n diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, false);\n }\n else\n {\n if (me.isInTicks(finalLabelMoment, me.tickUnit))\n // No floor needed since we are one of the ticks\n diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, false);\n else\n diff = finalLabelMoment.diff(me.firstTick, me.tickUnit, true);\n }\n scaleLabelDiffs.push(diff);\n }\n }\n me.labelDiffs[datasetIndex] = scaleLabelDiffs;\n scaleLabelDiffs = [];\n }, me);\n }\n\n\n },\n\n // This function is different from parent because the second argument of the index inside the array of dates\n // e.g [initialDate, endDate]. Since we built the diffs in date order, which means that every 2 entries in\n // me.labelDiffs represent one set of date with initial and end dates by order.\n getLabelDiff: function (datasetIndex, dateIndex) {\n var me = this;\n if (datasetIndex === null || dateIndex === null)\n return null;\n\n if (me.labelDiffs === undefined)\n me.buildLabelDiffs();\n\n if (me.labelDiffs[datasetIndex] != undefined)\n return me.labelDiffs[datasetIndex][dateIndex];\n\n return null;\n },\n\n getPixelForValue: function(value, index, datasetIndex) {\n var me = this;\n var offset = null;\n if (index !== undefined && datasetIndex !== undefined) {\n offset = me.getLabelDiff(datasetIndex, index);\n }\n\n if (offset === null) {\n if (!value || !value.isValid) {\n // not already a moment object\n value = me.parseTime(me.getRightValue(value));\n }\n if (value && value.isValid && value.isValid()) {\n offset = value.diff(me.firstTick, me.tickUnit, false);\n }\n }\n\n if (offset !== null) {\n var decimal = offset !== 0 ? offset / me.scaleSizeInUnits : offset;\n\n if (me.isHorizontal()) {\n var valueOffset = (me.width * decimal);\n return me.left + Math.round(valueOffset);\n }\n\n var heightOffset = (me.height * decimal);\n return me.top + Math.round(heightOffset);\n }\n },\n\n // Checks if some date object is a tickMoment\n isInTicks: function (date, unit) {\n var result = false;\n var length = this.tickMoments.length;\n var ticks = this.tickMoments;\n for(var i = 0; i < length; i++)\n {\n var tick = ticks[i];\n if (date.isSame(tick, unit))\n {\n result = true;\n break;\n }\n }\n return result;\n }\n});\n\n\nChart.scaleService.registerScaleType('myTime', myTimeScale, myConfig);\n\n\n\nChart.controllers.timeLine = Chart.controllers.bar.extend({\n\n getBarBounds : function (bar) {\n var vm = bar._view;\n var x1, x2, y1, y2;\n\n x1 = vm.x;\n x2 = vm.x + vm.width;\n y1 = vm.y;\n y2 = vm.y + vm.height;\n\n return {\n left : x1,\n top: y1,\n right: x2,\n bottom: y2\n };\n\n },\n\n update: function(reset) {\n var me = this;\n var meta = me.getMeta();\n helpers.each(meta.data, function(rectangle, index) {\n me.updateElement(rectangle, index, reset);\n }, me);\n },\n\n updateElement: function(rectangle, index, reset) {\n var me = this;\n var meta = me.getMeta();\n var xScale = me.getScaleForId(meta.xAxisID);\n var yScale = me.getScaleForId(meta.yAxisID);\n var dataset = me.getDataset();\n var data = dataset.data[index];\n var custom = rectangle.custom || {};\n var datasetIndex = me.index;\n var rectangleElementOptions = me.chart.options.elements.rectangle;\n\n rectangle._xScale = xScale;\n rectangle._yScale = yScale;\n rectangle._datasetIndex = me.index;\n rectangle._index = index;\n\n var ruler = me.getRuler(index);\n\n if (index !== 0)\n index = index * 2;\n\n var x = xScale.getPixelForValue(data, index , datasetIndex);\n index++;\n var end = xScale.getPixelForValue(data, index, datasetIndex);\n\n var y = yScale.getPixelForValue(data, datasetIndex, datasetIndex);\n var width = end - x;\n var height = me.calculateBarHeight(ruler);\n var color = me.chart.options.colorFunction(data);\n\n // This one has in account the size of the tick and the height of the bar, so we just\n // divide both of them by two and subtract the height part and add the tick part\n // to the real position of the element y. The purpose here is to place the bar\n // in the middle of the tick.\n var boxY = y + (ruler.tickHeight / 2) - (height / 2);\n\n // console.log(me.chart.data.labels[index] + ' box x ' + index + ' : ' + x);\n // console.log(me.chart.data.labels[index] + ' box y ' + index + ' : ' + boxY);\n rectangle._model = {\n x: reset ? x - width : x, // Top left of rectangle\n y: boxY , // Top left of rectangle\n width: width,\n height: height,\n base: x + width,\n backgroundColor: color,\n borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleElementOptions.borderSkipped,\n borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor),\n borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth),\n // Tooltip\n label: me.chart.data.labels[index],\n datasetLabel: dataset.label\n };\n\n\n\n rectangle.draw = function() {\n var ctx = this._chart.ctx;\n var vm = this._view;\n ctx.fillStyle = vm.backgroundColor;\n ctx.lineWidth = vm.borderWidth;\n helpers.drawRoundedRectangle(ctx, vm.x, vm.y, vm.width, vm.height, 1);\n ctx.fill();\n };\n\n rectangle.inXRange = function (mouseX) {\n var bounds = me.getBarBounds(this);\n return mouseX >= bounds.left && mouseX <= bounds.right;\n };\n rectangle.tooltipPosition = function () {\n var vm = this.getCenterPoint();\n return {\n x: vm.x ,\n y: vm.y\n };\n };\n\n rectangle.getCenterPoint = function () {\n var vm = this._view;\n var x, y;\n x = vm.x + (vm.width / 2);\n y = vm.y + (vm.height / 2);\n\n return {\n x : x,\n y : y\n };\n };\n\n rectangle.inRange = function (mouseX, mouseY) {\n var inRange = false;\n\n if(this._view)\n {\n var bounds = me.getBarBounds(this);\n inRange = mouseX >= bounds.left && mouseX <= bounds.right &&\n mouseY >= bounds.top && mouseY <= bounds.bottom;\n }\n return inRange;\n };\n\n rectangle.pivot();\n },\n\n // From controller.bar\n getRuler: function(index) {\n var me = this;\n var meta = me.getMeta();\n var yScale = me.getScaleForId(meta.yAxisID);\n var datasetCount = me.getBarCount();\n\n var tickHeight;\n if (yScale.options.type === 'category') {\n tickHeight = yScale.getPixelForTick(index + 1) - yScale.getPixelForTick(index);\n } else {\n // Average width\n tickHeight = yScale.width / yScale.ticks.length;\n }\n var categoryHeight = tickHeight * yScale.options.categoryPercentage;\n var categorySpacing = (tickHeight - (tickHeight * yScale.options.categoryPercentage)) / 2;\n var fullBarHeight = categoryHeight / datasetCount;\n\n if (yScale.ticks.length !== me.chart.data.labels.length) {\n var perc = yScale.ticks.length / me.chart.data.labels.length;\n fullBarHeight = fullBarHeight * perc;\n }\n\n var barHeight = fullBarHeight * yScale.options.barPercentage;\n var barSpacing = fullBarHeight - (fullBarHeight * yScale.options.barPercentage);\n\n return {\n datasetCount: datasetCount,\n tickHeight: tickHeight,\n categoryHeight: categoryHeight,\n categorySpacing: categorySpacing,\n fullBarHeight: fullBarHeight,\n barHeight: barHeight,\n barSpacing: barSpacing\n };\n },\n\n // From controller.bar\n getBarCount: function() {\n var me = this;\n var barCount = 0;\n helpers.each(me.chart.data.datasets, function(dataset, datasetIndex) {\n var meta = me.chart.getDatasetMeta(datasetIndex);\n if (meta.bar && me.chart.isDatasetVisible(datasetIndex)) {\n ++barCount;\n }\n }, me);\n return barCount;\n },\n\n\n // draw\n draw: function (ease) {\n var easingDecimal = ease || 1;\n var i, len;\n var metaData = this.getMeta().data;\n for (i = 0, len = metaData.length; i < len; i++)\n {\n metaData[i].transition(easingDecimal).draw();\n }\n },\n\n // From controller.bar\n calculateBarHeight: function(ruler) {\n var me = this;\n var yScale = me.getScaleForId(me.getMeta().yAxisID);\n if (yScale.options.barThickness) {\n return yScale.options.barThickness;\n }\n return yScale.options.stacked ? ruler.categoryHeight : ruler.barHeight;\n },\n\n removeHoverStyle: function(e) {\n // TODO\n },\n\n setHoverStyle: function(e) {\n // TODO: Implement this\n }\n\n});\n\n\nChart.defaults.timeLine = {\n\n colorFunction: function() {\n return 'black';\n },\n\n layout: {\n padding: {\n left: 5,\n right: 5,\n top: 0\n }\n },\n\n legend: {\n display: false\n },\n\n scales: {\n xAxes: [{\n type: 'myTime',\n position: 'bottom',\n gridLines: {\n display: true,\n offsetGridLines: true,\n drawBorder: true,\n drawTicks: true\n },\n ticks: {\n maxRotation: 0\n },\n unit: 'day'\n }],\n yAxes: [{\n type: 'category',\n position: 'left',\n barThickness : 20,\n gridLines: {\n display: true,\n offsetGridLines: true,\n drawBorder: true,\n drawTicks: true\n }\n }]\n },\n tooltips: {\n mode: 'single',\n callbacks: {\n title: function(tooltipItems, data) {\n return data.labels[tooltipItems[0].datasetIndex];\n },\n label: function(tooltipItem, data) {\n \n return [\"Started: \" + moment(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][0]).fromNow(),\n \"Duration: \" + moment(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][1]).from(data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index][0], true)\n ]\n }\n }\n }\n};\n\n</script>","storeOutMessages":true,"fwdInMessages":true,"templateScope":"global","x":470,"y":60,"wires":[[]]},{"id":"3b6fc506.b120ca","type":"ui_group","z":"","name":"Default Group","tab":"39af89f4.368cc6","disp":true,"width":"16"},{"id":"39af89f4.368cc6","type":"ui_tab","z":"","name":"Home Tab","icon":"dashboard","order":18,"disabled":false,"hidden":false}]
As Dave mentioned, the Vega nodes are designed to do things like this, and more -- but it's a steep learning curve!