From 7adfcb4b344aaa073b7dbddc0f1aaa8a7b9cc6ed Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Mon, 16 Dec 2019 21:34:01 -0500 Subject: [PATCH] Fixed #164 - Convert recent activity to chart2 Also: Fixed #177, Fixed #91, Fixed #86, Fixed #40. Big changes. --- api/runtime_thermostat.php | 8 + app.php | 10 +- css/dashboard.css | 4 + js/beestat/dispatcher.js | 7 +- js/beestat/setting.js | 18 +- js/beestat/style.js | 16 +- js/component/card/recent_activity.js | 1445 ----------------- js/component/card/runtime_detail.js | 835 ++++++++++ .../card/runtime_thermostat_summary.js | 2 +- js/component/card/temperature_profiles.js | 2 +- js/component/chart.js | 559 ++++--- js/component/chart/runtime_detail.js | 420 +++++ .../chart/runtime_thermostat_summary.js | 16 +- js/component/chart/temperature_profiles.js | 24 +- js/component/chart2.js | 476 ------ ...ent_activity.js => help_runtime_detail.js} | 58 +- js/component/modal/runtime_detail_custom.js | 373 +++++ js/js.php | 7 +- js/layer/dashboard.js | 4 +- js/layer/load.js | 3 +- 20 files changed, 2084 insertions(+), 2203 deletions(-) delete mode 100755 js/component/card/recent_activity.js create mode 100644 js/component/card/runtime_detail.js create mode 100644 js/component/chart/runtime_detail.js delete mode 100755 js/component/chart2.js rename js/component/modal/{help_recent_activity.js => help_runtime_detail.js} (59%) create mode 100644 js/component/modal/runtime_detail_custom.js diff --git a/api/runtime_thermostat.php b/api/runtime_thermostat.php index 0f1b098..61d393b 100755 --- a/api/runtime_thermostat.php +++ b/api/runtime_thermostat.php @@ -622,6 +622,14 @@ class runtime_thermostat extends cora\crud { throw new \Exception('Max range is 30 days.', 10205); } + // Accept timestamps in roughly any format; always convert back to something nice and in UTC + if (is_array($attributes['timestamp']['value']) === true) { + $attributes['timestamp']['value'][0] = date('c', strtotime($attributes['timestamp']['value'][0])); + $attributes['timestamp']['value'][1] = date('c', strtotime($attributes['timestamp']['value'][1])); + } else { + $attributes['timestamp']['value'] = date('c', strtotime($attributes['timestamp']['value'])); + } + // Read the data. $runtime_thermostats = $this->database->read( 'runtime_thermostat', diff --git a/app.php b/app.php index d5f7dd1..567c200 100644 --- a/app.php +++ b/app.php @@ -1,10 +1,10 @@ diff --git a/css/dashboard.css b/css/dashboard.css index 5311b45..1a3d041 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -6,7 +6,11 @@ html { box-sizing: inherit; } +/* Highcharts */ .highcharts-container, .highcharts-container svg { width: 100% !important; } +.crisp_edges { + shape-rendering: crispEdges; +} body { background: #111; diff --git a/js/beestat/dispatcher.js b/js/beestat/dispatcher.js index b47693d..0cac3b0 100644 --- a/js/beestat/dispatcher.js +++ b/js/beestat/dispatcher.js @@ -15,11 +15,16 @@ beestat.dispatcher = new beestat.dispatcher_(); * * @param {string|array} type The event type or an array of event types. * @param {Function} listener Event Listener. + * + * @return {beestat.dispatcher_} this. */ beestat.dispatcher_.prototype.addEventListener = function(type, listener) { if (typeof type === 'object') { for (var i = 0; i < type.length; i++) { - rocket.EventTarget.prototype.addEventListener.apply(this, [type[i], listener]); + rocket.EventTarget.prototype.addEventListener.apply(this, [ + type[i], + listener + ]); } } else { rocket.EventTarget.prototype.addEventListener.apply(this, arguments); diff --git a/js/beestat/setting.js b/js/beestat/setting.js index 62a6872..3e64300 100644 --- a/js/beestat/setting.js +++ b/js/beestat/setting.js @@ -12,8 +12,13 @@ beestat.setting = function(key, opt_value, opt_callback) { var user = beestat.get_user(); var defaults = { - 'recent_activity_time_period': 'day', - 'recent_activity_time_count': 3, + 'runtime_detail_smoothing': true, + 'runtime_detail_range_type': 'dynamic', + 'runtime_detail_range_static_begin': moment() + .subtract(3, 'day') + .format('MM/DD/YYYY'), + 'runtime_detail_range_static_end': moment().format('MM/DD/YYYY'), + 'runtime_detail_range_dynamic': 3, 'runtime_thermostat_summary_time_count': 0, 'runtime_thermostat_summary_time_period': 'all', @@ -82,8 +87,15 @@ beestat.setting = function(key, opt_value, opt_callback) { } } - // If no settings changed no API call needs to be fired. + /** + * If no settings changed no API call needs to be fired. In that case also + * fire the callback since the API isn't doing it. + */ if (has_calls === true) { api.send(); + } else { + if (opt_callback !== undefined) { + opt_callback(); + } } }; diff --git a/js/beestat/style.js b/js/beestat/style.js index c88c69a..4da55b7 100644 --- a/js/beestat/style.js +++ b/js/beestat/style.js @@ -191,16 +191,16 @@ beestat.series.indoor_humidity = { beestat.series.outdoor_humidity = { 'name': 'Outdoor Humidity', - 'color': beestat.style.color.green.light + 'color': beestat.style.color.bluegreen.base }; beestat.series.setpoint_heat = { - 'name': 'Setpoint', + 'name': 'Heat Setpoint', 'color': beestat.style.color.orange.light }; beestat.series.setpoint_cool = { - 'name': 'Setpoint', + 'name': 'Cool Setpoint', 'color': beestat.style.color.lightblue.light }; @@ -225,11 +225,6 @@ beestat.series.extreme_outdoor_temperature = { 'color': beestat.style.color.gray.dark }; -beestat.series.setpoint_cool = { - 'name': 'Setpoint', - 'color': beestat.style.color.lightblue.light -}; - beestat.series.calendar_event_home = { 'name': 'Home', 'color': beestat.style.color.green.dark @@ -280,6 +275,11 @@ beestat.series.calendar_event_other = { 'color': beestat.style.color.gray.base }; +beestat.series.calendar_event_custom = { + 'name': 'Custom', + 'color': beestat.style.color.bluegreen.base +}; + // Temperature Profiles beestat.series.indoor_heat_delta = { 'name': 'Indoor Heat Δ', diff --git a/js/component/card/recent_activity.js b/js/component/card/recent_activity.js deleted file mode 100755 index 6a1dbf0..0000000 --- a/js/component/card/recent_activity.js +++ /dev/null @@ -1,1445 +0,0 @@ -/** - * Recent activity card. Shows a graph similar to what ecobee shows with the - * runtime info for a recent period of time. - */ -beestat.component.card.recent_activity = function() { - beestat.component.card.apply(this, arguments); -}; -beestat.extend(beestat.component.card.recent_activity, beestat.component.card); - -beestat.component.card.recent_activity.optional_series = [ - 'compressor_heat_1', - 'compressor_heat_2', - 'compressor_cool_1', - 'compressor_cool_2', - 'auxiliary_heat_1', - 'auxiliary_heat_2', - 'fan', - 'dehumidifier', - 'economizer', - 'humidifier', - 'ventilator' -]; - -beestat.component.card.recent_activity.calendar_events = [ - 'calendar_event_home', - 'calendar_event_away', - 'calendar_event_sleep', - 'calendar_event_vacation', - 'calendar_event_smarthome', - 'calendar_event_smartaway', - 'calendar_event_smartrecovery', - 'calendar_event_hold', - 'calendar_event_quicksave', - 'calendar_event_other' -]; - -/** - * Decorate - * - * @param {rocket.ELements} parent - */ -beestat.component.card.recent_activity.prototype.decorate_contents_ = function(parent) { - var self = this; - - var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; - - this.chart_ = new beestat.component.chart(); - var series = this.get_series_(); - - this.chart_.options.chart.backgroundColor = beestat.style.color.bluegray.base; - this.chart_.options.exporting.filename = thermostat.name + ' - Recent Activity'; - this.chart_.options.exporting.chartOptions.title.text = this.get_title_(); - this.chart_.options.exporting.chartOptions.subtitle.text = this.get_subtitle_(); - - var current_day; - var current_hour; - this.chart_.options.xAxis = { - 'categories': series.x.chart_data, - 'type': 'datetime', - 'lineColor': beestat.style.color.bluegray.light, - 'min': series.x.chart_data[0], - 'max': series.x.chart_data[series.x.chart_data.length - 1], - 'minRange': 21600000, - 'tickLength': 0, - 'gridLineWidth': 0, - 'labels': { - 'style': {'color': beestat.style.color.gray.base}, - 'formatter': function() { - var m = moment(this.value); - var hour = m.format('ha'); - var day = m.format('ddd'); - - var label_parts = []; - if (day !== current_day) { - label_parts.push(day); - } - if (hour !== current_hour) { - label_parts.push(hour); - } - - current_hour = hour; - current_day = day; - - return label_parts.join(' '); - } - } - }; - - // Add some space for the top of the graph. - this.y_max_ += 30; - - // Because higcharts isn't respecting the tickInterval parameter...seems to - // have to do with the secondary axis; as removing it makes it work a lot - // better. - var tick_positions = []; - var tick_interval = (thermostat.temperature_unit === '°F') ? 10 : 5; - var current_tick_position = - Math.floor(this.y_min_ / tick_interval) * tick_interval; - while (current_tick_position <= this.y_max_) { - tick_positions.push(current_tick_position); - current_tick_position += tick_interval; - } - - this.chart_.options.yAxis = [ - // Temperature - { - // 'alignTicks': false, // Uncommenting this will allow the humidity series to line up but it will also force the y-axis to be a bit larger. For example, a y min of 17 will get set to a min of 0 instead of 15 because the spacing is set to 20. - 'gridLineColor': beestat.style.color.bluegray.light, - 'gridLineDashStyle': 'longdash', - 'title': {'text': null}, - 'labels': { - 'style': {'color': beestat.style.color.gray.base}, - 'formatter': function() { - return this.value + thermostat.temperature_unit; - } - }, - 'tickPositions': tick_positions - }, - - // Top bars - { - 'height': 100, - 'min': 0, - 'max': 100, - 'gridLineWidth': 0, - 'title': {'text': null}, - 'labels': {'enabled': false} - }, - - // Humidity - { - 'alignTicks': false, - 'gridLineColor': null, - 'tickInterval': 10, - // 'gridLineDashStyle': 'longdash', - 'opposite': true, - 'title': {'text': null}, - 'labels': { - 'style': {'color': beestat.style.color.gray.base}, - 'formatter': function() { - return this.value + '%'; - } - }, - - /* - * If you set a min/max highcharts always shows the axis. Setting these - * attributes prevents the "always show" logic and the 0-100 is achieved - * with this set of parameters. - * https://github.com/highcharts/highcharts/issues/3403 - */ - 'min': 0, - 'minRange': 100, - 'ceiling': 100 - } - ]; - - this.chart_.options.tooltip = { - 'shared': true, - 'useHTML': true, - 'borderWidth': 0, - 'shadow': false, - 'backgroundColor': null, - 'followPointer': true, - 'crosshairs': { - 'width': 1, - 'zIndex': 100, - 'color': beestat.style.color.gray.light, - 'dashStyle': 'shortDot', - 'snap': false - }, - 'positioner': function(tooltip_width, tooltip_height, point) { - return beestat.component.chart.tooltip_positioner( - self.chart_.get_chart(), - tooltip_width, - tooltip_height, - point - ); - }, - 'formatter': function() { - var self = this; - - var sections = []; - - // HVAC Mode - var system_mode; - var system_mode_color; - - switch (series.system_mode.data[self.x]) { - case 'auto': - system_mode = 'Auto'; - system_mode_color = beestat.style.color.gray.base; - break; - case 'heat': - system_mode = 'Heat'; - system_mode_color = beestat.series.compressor_heat_1.color; - break; - case 'cool': - system_mode = 'Cool'; - system_mode_color = beestat.series.compressor_cool_1.color; - break; - case 'off': - system_mode = 'Off'; - system_mode_color = beestat.style.color.gray.base; - break; - case 'auxiliary_heat': - system_mode = 'Aux'; - system_mode_color = beestat.series.auxiliary_heat_1.color; - break; - } - - var section_1 = []; - sections.push(section_1); - - if (system_mode !== undefined) { - section_1.push({ - 'label': 'Mode', - 'value': system_mode, - 'color': system_mode_color - }); - } - - // Calendar Event / Comfort Profile - var event; - var event_color; - - for (var i = 0; i < beestat.component.card.recent_activity.calendar_events.length; i++) { - var calendar_event = beestat.component.card.recent_activity.calendar_events[i]; - if (series[calendar_event].data[self.x] !== null) { - event = beestat.series[calendar_event].name; - event_color = beestat.series[calendar_event].color; - break; - } - } - - if (event !== undefined) { - section_1.push({ - 'label': 'Comfort Profile', - 'value': event, - 'color': event_color - }); - } - - var section_2 = []; - sections.push(section_2); - - [ - 'setpoint_heat', - 'setpoint_cool', - 'indoor_temperature', - 'outdoor_temperature', - 'indoor_humidity', - 'outdoor_humidity' - ].forEach(function(series_code) { - var value; - - if (series_code === 'setpoint_cool') { - return; // Grab it when doing setpoint_heat - } else if (series_code === 'setpoint_heat') { - if ( - series[series_code].data[self.x] === null - ) { - return; - } - - switch (series.system_mode.data[self.x]) { - case 'heat': - if (series.setpoint_heat.data[self.x] === null) { - return; - } - value = beestat.temperature({ - 'temperature': series.setpoint_heat.data[self.x], - 'convert': false, - 'units': true - }); - break; - case 'cool': - if (series.setpoint_cool.data[self.x] === null) { - return; - } - value = beestat.temperature({ - 'temperature': series.setpoint_cool.data[self.x], - 'convert': false, - 'units': true - }); - break; - case 'auto': - if ( - series.setpoint_heat.data[self.x] === null || - series.setpoint_cool.data[self.x] === null - ) { - return; - } - value = beestat.temperature({ - 'temperature': series.setpoint_heat.data[self.x], - 'convert': false, - 'units': true - }); - value += ' - '; - value += beestat.temperature({ - 'temperature': series.setpoint_cool.data[self.x], - 'convert': false, - 'units': true - }); - break; - default: - return; - break; - } - } else if ( - series_code === 'indoor_humidity' || - series_code === 'outdoor_humidity' - ) { - if (series[series_code].data[self.x] === null) { - return; - } - value = series[series_code].data[self.x] + '%'; - } else { - if (series[series_code].data[self.x] === null) { - return; - } - value = beestat.temperature({ - 'temperature': series[series_code].data[self.x], - 'convert': false, - 'units': true - }); - } - - section_2.push({ - 'label': beestat.series[series_code].name, - 'value': value, - 'color': beestat.style.color.gray.light - }); - }); - - var section_3 = []; - sections.push(section_3); - - beestat.component.card.recent_activity.optional_series.forEach(function(series_code) { - if ( - series[series_code].data[self.x] !== undefined && - series[series_code].data[self.x] !== null - ) { - section_3.push({ - 'label': beestat.series[series_code].name, - 'value': beestat.time(series[series_code].durations[self.x].seconds), - 'color': beestat.series[series_code].color - }); - } - }); - - return beestat.component.chart.tooltip_formatter( - moment(this.x).format('ddd, MMM D @ h:mma'), - sections - ); - } - }; - - this.chart_.options.series = []; - - beestat.component.card.recent_activity.calendar_events.forEach(function(calendar_event) { - self.chart_.options.series.push({ - 'id': calendar_event, - 'linkedTo': (calendar_event !== 'calendar_event_home') ? 'calendar_event_home' : undefined, - 'data': series[calendar_event].chart_data, - 'yAxis': 1, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': 'Comfort Profile', - 'type': 'line', - 'color': beestat.series[calendar_event].color, - 'lineWidth': 5, - 'linecap': 'square', - 'states': {'hover': {'lineWidthPlus': 0}} - }); - }); - - if (series.compressor_cool_1.enabled === true) { - this.chart_.options.series.push({ - 'id': 'compressor_cool_1', - 'data': series.compressor_cool_1.chart_data, - 'yAxis': 1, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': 'Cool', - 'type': 'line', - 'color': beestat.series.compressor_cool_1.color, - 'lineWidth': 10, - 'linecap': 'square', - 'states': {'hover': {'lineWidthPlus': 0}} - }); - } - - if (series.compressor_cool_2.enabled === true) { - this.chart_.options.series.push({ - 'data': series.compressor_cool_2.chart_data, - 'linkedTo': 'compressor_cool_1', - 'yAxis': 1, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': beestat.series.compressor_cool_2.name, - 'type': 'line', - 'color': beestat.series.compressor_cool_2.color, - 'lineWidth': 10, - 'linecap': 'square', - 'states': {'hover': {'lineWidthPlus': 0}} - }); - } - - if (series.compressor_heat_1.enabled === true) { - this.chart_.options.series.push({ - 'id': 'compressor_heat_1', - 'data': series.compressor_heat_1.chart_data, - 'yAxis': 1, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': 'Heat', - 'type': 'line', - 'color': beestat.series.compressor_heat_1.color, - 'lineWidth': 10, - 'linecap': 'square', - 'states': {'hover': {'lineWidthPlus': 0}} - }); - } - - if (series.compressor_heat_2.enabled === true) { - this.chart_.options.series.push({ - 'linkedTo': 'compressor_heat_1', - 'data': series.compressor_heat_2.chart_data, - 'yAxis': 1, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': beestat.series.compressor_heat_2.name, - 'type': 'line', - 'color': beestat.series.compressor_heat_2.color, - 'lineWidth': 10, - 'linecap': 'square', - 'states': {'hover': {'lineWidthPlus': 0}} - }); - } - - [ - 'auxiliary_heat_1', - 'auxiliary_heat_2' - ].forEach(function(equipment) { - if (series[equipment].enabled === true) { - self.chart_.options.series.push({ - 'data': series[equipment].chart_data, - 'yAxis': 1, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': beestat.series[equipment].name, - 'type': 'line', - 'color': beestat.series[equipment].color, - 'lineWidth': 10, - 'linecap': 'square', - 'states': {'hover': {'lineWidthPlus': 0}} - }); - } - }); - - if (series.fan.enabled === true) { - this.chart_.options.series.push({ - 'data': series.fan.chart_data, - 'yAxis': 1, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': beestat.series.fan.name, - 'type': 'line', - 'color': beestat.series.fan.color, - 'lineWidth': 5, - 'linecap': 'square', - 'states': {'hover': {'lineWidthPlus': 0}} - }); - } - - [ - 'dehumidifier', - 'economizer', - 'humidifier', - 'ventilator' - ].forEach(function(equipment) { - if (series[equipment].enabled === true) { - self.chart_.options.series.push({ - 'data': series[equipment].chart_data, - 'yAxis': 1, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': beestat.series[equipment].name, - 'type': 'line', - 'color': beestat.series[equipment].color, - 'lineWidth': 5, - 'linecap': 'square', - 'states': {'hover': {'lineWidthPlus': 0}} - }); - } - }); - - this.chart_.options.series.push({ - 'id': 'indoor_humidity', - 'data': series.indoor_humidity.chart_data, - 'yAxis': 2, - 'name': beestat.series.indoor_humidity.name, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'dashStyle': 'DashDot', - 'visible': false, - 'lineWidth': 1, - 'color': beestat.series.indoor_humidity.color, - 'states': {'hover': {'lineWidthPlus': 0}}, - - /* - * Weird HighCharts bug... - * https://stackoverflow.com/questions/48374093/highcharts-highstock-line-change-to-area-bug - * https://github.com/highcharts/highcharts/issues/766 - */ - 'linecap': 'square' - }); - - this.chart_.options.series.push({ - 'id': 'outdoor_humidity', - 'data': series.outdoor_humidity.chart_data, - 'yAxis': 2, - 'name': beestat.series.outdoor_humidity.name, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'dashStyle': 'DashDot', - 'visible': false, - 'lineWidth': 1, - 'color': beestat.series.outdoor_humidity.color, - 'states': {'hover': {'lineWidthPlus': 0}}, - - /* - * Weird HighCharts bug... - * https://stackoverflow.com/questions/48374093/highcharts-highstock-line-change-to-area-bug - * https://github.com/highcharts/highcharts/issues/766 - */ - 'linecap': 'square' - }); - - this.chart_.options.series.push({ - 'data': series.indoor_temperature.chart_data, - 'yAxis': 0, - 'name': beestat.series.indoor_temperature.name, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'lineWidth': 2, - 'color': beestat.series.indoor_temperature.color, - 'states': {'hover': {'lineWidthPlus': 0}}, - - /* - * Weird HighCharts bug... - * https://stackoverflow.com/questions/48374093/highcharts-highstock-line-change-to-area-bug - * https://github.com/highcharts/highcharts/issues/766 - */ - 'linecap': 'square' - }); - - this.chart_.options.series.push({ - 'color': beestat.series.outdoor_temperature.color, - 'data': series.outdoor_temperature.chart_data, - // 'zones': beestat.component.chart.get_outdoor_temperature_zones(), - 'yAxis': 0, - 'name': beestat.series.outdoor_temperature.name, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'type': 'spline', - 'dashStyle': 'ShortDash', - 'lineWidth': 1, - 'states': {'hover': {'lineWidthPlus': 0}} - }); - - this.chart_.options.series.push({ - 'data': series.setpoint_heat.chart_data, - 'id': 'setpoint_heat', - 'yAxis': 0, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': beestat.series.setpoint_heat.name, - 'type': 'line', - 'color': beestat.series.setpoint_heat.color, - 'lineWidth': 1, - 'states': {'hover': {'lineWidthPlus': 0}}, - 'step': 'right' - }); - - this.chart_.options.series.push({ - 'data': series.setpoint_cool.chart_data, - 'yAxis': 0, - 'marker': { - 'enabled': false, - 'states': {'hover': {'enabled': false}} - }, - 'name': beestat.series.setpoint_cool.name, - 'type': 'line', - 'color': beestat.series.setpoint_cool.color, - 'lineWidth': 1, - 'states': {'hover': {'lineWidthPlus': 0}}, - 'step': 'right' - }); - - this.chart_.render(parent); - - this.show_loading_('Syncing Recent Activity'); - - /* - * If the data is available, then get the data if we don't already have it - * loaded. If the data is not available, poll until it becomes available. - */ - if (this.data_available_() === true) { - if (beestat.cache.runtime_thermostat.length === 0) { - this.get_data_(); - } else { - this.hide_loading_(); - } - } else { - var poll_interval = 10000; - - beestat.add_poll_interval(poll_interval); - beestat.dispatcher.addEventListener('poll.recent_activity_load', function() { - if (self.data_available_() === true) { - beestat.remove_poll_interval(poll_interval); - beestat.dispatcher.removeEventListener('poll.recent_activity_load'); - self.get_data_(); - } - }); - } -}; - -/** - * Decorate the menu - * - * @param {rocket.Elements} parent - */ -beestat.component.card.recent_activity.prototype.decorate_top_right_ = function(parent) { - var self = this; - - var menu = (new beestat.component.menu()).render(parent); - - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Past 1 Day') - .set_icon('numeric_1_box') - .set_callback(function() { - if ( - beestat.setting('recent_activity_time_count') !== 1 || - beestat.setting('recent_activity_time_period') !== 'day' - ) { - beestat.setting({ - 'recent_activity_time_count': 1, - 'recent_activity_time_period': 'day' - }); - - /* - * Rerender; the timeout lets the menu close immediately without being - * blocked by the time it takes to rerender the chart. - */ - setTimeout(function() { - self.rerender(); - }, 0); - } - })); - - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Past 3 Days') - .set_icon('numeric_3_box') - .set_callback(function() { - if ( - beestat.setting('recent_activity_time_count') !== 3 || - beestat.setting('recent_activity_time_period') !== 'day' - ) { - beestat.setting({ - 'recent_activity_time_count': 3, - 'recent_activity_time_period': 'day' - }); - - setTimeout(function() { - self.rerender(); - }, 0); - } - })); - - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Past 7 Days') - .set_icon('numeric_7_box') - .set_callback(function() { - if ( - beestat.setting('recent_activity_time_count') !== 7 || - beestat.setting('recent_activity_time_period') !== 'day' - ) { - beestat.setting({ - 'recent_activity_time_count': 7, - 'recent_activity_time_period': 'day' - }); - setTimeout(function() { - self.rerender(); - }, 0); - } - })); - - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Download Chart') - .set_icon('download') - .set_callback(function() { - self.chart_.get_chart().exportChartLocal(); - })); - - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Reset Zoom') - .set_icon('magnify_minus') - .set_callback(function() { - self.chart_.get_chart().zoomOut(); - })); - - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Help') - .set_icon('help_circle') - .set_callback(function() { - (new beestat.component.modal.help_recent_activity()).render(); - })); -}; - -/** - * Get all of the series data. - * - * @return {object} The series data. - */ -beestat.component.card.recent_activity.prototype.get_series_ = function() { - var self = this; - - var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; - - /* - * The more data that gets shown the larger the smoothing factor should be - * (less points, smoother graph). - */ - var smoothing_factor = beestat.setting('recent_activity_time_count') * 3; - - this.y_min_ = Infinity; - this.y_max_ = -Infinity; - - /* - * The chart_data property is what Highcharts uses. The data property is the - * same data indexed by the x value to make it easy to access. - */ - var series = { - 'x': { - 'enabled': true, - 'chart_data': [], - 'data': {} - }, - 'setpoint_heat': { - 'enabled': true, - 'chart_data': [], - 'data': {} - }, - 'setpoint_cool': { - 'enabled': true, - 'chart_data': [], - 'data': {} - }, - 'outdoor_temperature': { - 'enabled': true, - 'chart_data': [], - 'data': {} - }, - 'indoor_temperature': { - 'enabled': true, - 'chart_data': [], - 'data': {} - }, - 'indoor_humidity': { - 'enabled': true, - 'chart_data': [], - 'data': {} - }, - 'outdoor_humidity': { - 'enabled': true, - 'chart_data': [], - 'data': {} - }, - 'system_mode': { - 'enabled': true, - 'chart_data': [], - 'data': {} - } - }; - - // Initialize the optional series. - beestat.component.card.recent_activity.optional_series.forEach(function(optional_series) { - series[optional_series] = { - 'enabled': false, - 'chart_data': [], - 'data': {}, - 'durations': {} - }; - }); - - // Initialize the calendar event series. - beestat.component.card.recent_activity.calendar_events.forEach(function(calendar_event) { - series[calendar_event] = { - 'enabled': false, - 'chart_data': [], - 'data': {} - }; - }); - - /* - * Overrides the %10 smoothing for when there is missing data. Basically just - * ensures that the graph starts back up right away instead of waiting for a - * 10th data point. - */ - var previous_indoor_temperature_value = null; - var previous_outdoor_temperature_value = null; - var previous_indoor_humidity_value = null; - var previous_outdoor_humidity_value = null; - - var min_x = moment() - .subtract( - beestat.setting('recent_activity_time_count'), - beestat.setting('recent_activity_time_period') - ) - .valueOf(); - - /* - * This creates a distinct object for each chunk of runtime so the total on - * time can be computed for any given segment. - */ - var durations = {}; - - beestat.cache.runtime_thermostat.forEach(function(runtime_thermostat, i) { - // if (runtime_thermostat.ecobee_thermostat_id !== thermostat.ecobee_thermostat_id) { - // return; - // } - // - - if (runtime_thermostat.compressor_mode === 'heat') { - runtime_thermostat.compressor_heat_1 = runtime_thermostat.compressor_1; - runtime_thermostat.compressor_heat_2 = runtime_thermostat.compressor_2; - runtime_thermostat.compressor_cool_1 = 0; - runtime_thermostat.compressor_cool_2 = 0; - } else if (runtime_thermostat.compressor_mode === 'cool') { - runtime_thermostat.compressor_heat_1 = 0; - runtime_thermostat.compressor_heat_2 = 0; - runtime_thermostat.compressor_cool_1 = runtime_thermostat.compressor_1; - runtime_thermostat.compressor_cool_2 = runtime_thermostat.compressor_2; - } else if (runtime_thermostat.compressor_mode === 'off') { - runtime_thermostat.compressor_heat_1 = 0; - runtime_thermostat.compressor_heat_2 = 0; - runtime_thermostat.compressor_cool_1 = 0; - runtime_thermostat.compressor_cool_2 = 0; - } else { - runtime_thermostat.compressor_heat_1 = null; - runtime_thermostat.compressor_heat_2 = null; - runtime_thermostat.compressor_cool_1 = null; - runtime_thermostat.compressor_cool_2 = null; - } - - // Set these to 0 because they don't exist anymore. - runtime_thermostat.humidifier = 0; - runtime_thermostat.dehumidifier = 0; - runtime_thermostat.ventilator = 0; - runtime_thermostat.economizer = 0; - - // Now set one to an appropriate value to make the rest of the code work. - if (runtime_thermostat.accessory_type !== 'off') { - runtime_thermostat[runtime_thermostat.accessory_type] = runtime_thermostat.accessory; - } - - - // The string includes +00:00 as the UTC offset but moment knows what time - // zone my PC is in...or at least it has a guess. This means that beestat - // graphs can now show up in local time instead of thermostat time. - var x = moment(runtime_thermostat.timestamp).valueOf(); - if (x < min_x) { - return; - } - - series.x.chart_data.push(x); - - var original_durations = {}; - if (runtime_thermostat.compressor_heat_2 > 0) { - original_durations.compressor_heat_1 = runtime_thermostat.compressor_heat_1; - runtime_thermostat.compressor_heat_1 = runtime_thermostat.compressor_heat_2; - } - // TODO DO THIS FOR AUX - // TODO DO THIS FOR COOL - - beestat.component.card.recent_activity.optional_series.forEach(function(series_code) { - if (durations[series_code] === undefined) { - durations[series_code] = [{'seconds': 0}]; - } - - // if (series_code === 'compressor_heat_1') { - // runtime_thermostat - // } - - if ( - runtime_thermostat[series_code] !== null && - runtime_thermostat[series_code] > 0 - ) { - var value; - switch (series_code) { - case 'fan': - value = 70; - break; - case 'dehumidifier': - case 'economizer': - case 'humidifier': - case 'ventilator': - value = 62; - break; - default: - value = 80; - break; - } - - series[series_code].enabled = true; - series[series_code].chart_data.push([ - x, - value - ]); - series[series_code].data[x] = value; - - var duration = original_durations[series_code] !== undefined - ? original_durations[series_code] - : runtime_thermostat[series_code]; - - durations[series_code][durations[series_code].length - 1].seconds += duration; - // durations[series_code][durations[series_code].length - 1].seconds += runtime_thermostat[series_code]; - series[series_code].durations[x] = durations[series_code][durations[series_code].length - 1]; - } else { - series[series_code].chart_data.push([ - x, - null - ]); - series[series_code].data[x] = null; - - if (durations[series_code][durations[series_code].length - 1].seconds > 0) { - durations[series_code].push({'seconds': 0}); - } - } - }); - - /* - * This is the ecobee code. - * - * var normalizedString = eventString; - * var vacationPattern = /(\S\S\S\s\d+\s\d\d\d\d)|(\d{12})/i; - * var smartRecoveryPattern = /smartRecovery/i; - * var smartAwayPattern = /smartAway/i; - * var smartHomePattern = /smartHome/i; - * var quickSavePattern = /quickSave/i; - * - * if (typeof eventString === 'string') { - * eventString = eventString.toLowerCase(); - * normalizedString = eventString; - * - * if (eventString === 'auto' || eventString === 'today' || eventString === 'hold' || typeof thermostatClimates.climates[eventString] !== 'undefined') { - * normalizedString = 'hold'; - * } else if (vacationPattern.test(eventString) || eventString.toLowerCase().indexOf('vacation') === 0) { - * normalizedString = 'vacation'; - * } else if(smartRecoveryPattern.test(eventString)) { - * normalizedString = 'smartRecovery'; - * } else if(smartHomePattern.test(eventString)) { - * normalizedString = 'smartHome'; - * } else if(smartAwayPattern.test(eventString)) { - * normalizedString = 'smartAway'; - * } else if(quickSavePattern.test(eventString)) { - * normalizedString = 'quickSave'; - * } else { - * normalizedString = 'customEvent'; - * } - * } - */ - - /* - * Here are some examples of what I get in the database and what they map to - * - * calendar_event_home home - * calendar_event_away away - * calendar_event_smartrecovery (SmartRecovery) - * calendar_event_smartrecovery smartAway(SmartRecovery) - * calendar_event_smartrecovery auto(SmartRecovery) - * calendar_event_smartrecovery hold(SmartRecovery) - * calendar_event_smartrecovery 149831444185(SmartRecovery) - * calendar_event_smartrecovery Vacation(SmartRecovery) - * calendar_event_smartrecovery 152304757299(SmartRecovery) - * calendar_event_smartrecovery Apr 29 2016(SmartRecovery) - * calendar_event_smarthome smartHome - * calendar_event_smartaway smartAway - * calendar_event_hold hold - * calendar_event_vacation Vacation - * calendar_event_quicksave QuickSave - * calendar_event_vacation 151282889098 - * calendar_event_vacation May 14 2016 - * calendar_event_hold auto - * calendar_event_other NULL - * calendar_event_other HKhold - * calendar_event_other 8915FC00B0DA - * calendar_event_other 769347151 - */ - - /* - * Thanks, ecobee...I more or less copied this code from the ecobee Follow - * Me graph to make sure it's as accurate as possible. - */ - var this_calendar_event; - - /* - * Display a fixed schedule in demo mode. - */ - if (window.is_demo === true) { - var m = moment(runtime_thermostat.timestamp); - - // Moment and ecobee use different indexes for the days of the week - var day_of_week_index = (m.day() + 6) % 7; - - // Ecobee splits the schedule up into 30 minute chunks; find the right one - var m_midnight = m.clone().startOf('day'); - var minute_of_day = m.diff(m_midnight, 'minutes'); - var chunk_of_day_index = Math.floor(minute_of_day / 30); // max 47 - - var ecobee_thermostat = beestat.cache.ecobee_thermostat[ - thermostat.ecobee_thermostat_id - ]; - - this_calendar_event = 'calendar_event_' + ecobee_thermostat.program.schedule[day_of_week_index][chunk_of_day_index]; - } else { - if (runtime_thermostat.event === null) { - if (runtime_thermostat.climate === null) { - this_calendar_event = 'calendar_event_other'; - } else { - this_calendar_event = 'calendar_event_' + runtime_thermostat.climate.toLowerCase(); - } - } else if (runtime_thermostat.event.match(/SmartRecovery/i) !== null) { - this_calendar_event = 'calendar_event_smartrecovery'; - } else if (runtime_thermostat.event.match(/^home$/i) !== null) { - this_calendar_event = 'calendar_event_home'; - } else if (runtime_thermostat.event.match(/^away$/i) !== null) { - this_calendar_event = 'calendar_event_away'; - } else if (runtime_thermostat.event.match(/^smarthome$/i) !== null) { - this_calendar_event = 'calendar_event_smarthome'; - } else if (runtime_thermostat.event.match(/^smartaway$/i) !== null) { - this_calendar_event = 'calendar_event_smartaway'; - } else if (runtime_thermostat.event.match(/^auto$/i) !== null) { - this_calendar_event = 'calendar_event_hold'; - } else if (runtime_thermostat.event.match(/^today$/i) !== null) { - this_calendar_event = 'calendar_event_hold'; - } else if (runtime_thermostat.event.match(/^hold$/i) !== null) { - this_calendar_event = 'calendar_event_hold'; - } else if (runtime_thermostat.event.match(/^vacation$/i) !== null) { - this_calendar_event = 'calendar_event_vacation'; - } else if (runtime_thermostat.event.match(/(\S\S\S\s\d+\s\d\d\d\d)|(\d{12})/i) !== null) { - this_calendar_event = 'calendar_event_vacation'; - } else if (runtime_thermostat.event.match(/^quicksave$/i) !== null) { - this_calendar_event = 'calendar_event_quicksave'; - } else { - this_calendar_event = 'calendar_event_other'; - } - } - - - // Dynamically add new calendar events for custom climates. - if ( - beestat.component.card.recent_activity.calendar_events.indexOf(this_calendar_event) === -1 - ) { - beestat.component.card.recent_activity.calendar_events.push(this_calendar_event); - - series[this_calendar_event] = { - 'enabled': false, - 'chart_data': [], - 'data': {}, - 'durations': {} - }; - - beestat.series[this_calendar_event] = { - 'name': runtime_thermostat.climate, - 'color': beestat.style.color.bluegreen.base - }; - } - - beestat.component.card.recent_activity.calendar_events.forEach(function(calendar_event) { - if (calendar_event === this_calendar_event && this_calendar_event !== 'calendar_event_other') { - var value = 95; - series[calendar_event].enabled = true; - series[calendar_event].chart_data.push([ - x, - value - ]); - series[calendar_event].data[x] = value; - } else { - series[calendar_event].chart_data.push([ - x, - null - ]); - series[calendar_event].data[x] = null; - } - }); - - /* - * HVAC Mode. This isn't graphed but it's available for the tooltip. - * series.system_mode.chart_data.push([x, runtime_thermostat.system_mode]); - */ - series.system_mode.data[x] = runtime_thermostat.system_mode; - - // Setpoints - var setpoint_value_heat = beestat.temperature({'temperature': runtime_thermostat.setpoint_heat}); - var setpoint_value_cool = beestat.temperature({'temperature': runtime_thermostat.setpoint_cool}); - - // NOTE: At one point I was also factoring in your heat/cool differential - // plus the extra degree offset ecobee adds when you are "away". That made - // the graph very exact but it wasn't really "setpoint" so I felt that would - // be confusing. - - if ( - runtime_thermostat.system_mode === 'auto' || - runtime_thermostat.system_mode === 'heat' || - runtime_thermostat.system_mode === 'auxiliary_heat' || - runtime_thermostat.system_mode === null // Need this for the explicit null to remove from the graph. - ) { - series.setpoint_heat.data[x] = setpoint_value_heat; - series.setpoint_heat.chart_data.push([ - x, - setpoint_value_heat - ]); - - if (setpoint_value_heat !== null) { - self.y_min_ = Math.min(self.y_min_, setpoint_value_heat); - self.y_max_ = Math.max(self.y_max_, setpoint_value_heat); - } - } else { - - /** - * Explicitly add a null entry to force an empty spot on the line. - * Otherwise Highcharts will connect gaps (see #119). - */ - series.setpoint_heat.data[x] = null; - series.setpoint_heat.chart_data.push([ - x, - null - ]); - } - - if ( - runtime_thermostat.system_mode === 'auto' || - runtime_thermostat.system_mode === 'cool' || - runtime_thermostat.system_mode === null // Need this for the explicit null to remove from the graph. - ) { - series.setpoint_cool.data[x] = setpoint_value_cool; - series.setpoint_cool.chart_data.push([ - x, - setpoint_value_cool - ]); - - if (setpoint_value_cool !== null) { - self.y_min_ = Math.min(self.y_min_, setpoint_value_cool); - self.y_max_ = Math.max(self.y_max_, setpoint_value_cool); - } - } else { - - /** - * Explicitly add a null entry to force an empty spot on the line. - * Otherwise Highcharts will connect gaps (see #119). - */ - series.setpoint_cool.data[x] = null; - series.setpoint_cool.chart_data.push([ - x, - null - ]); - } - - // Indoor temperature - var indoor_temperature_value = beestat.temperature(runtime_thermostat.indoor_temperature); - series.indoor_temperature.data[x] = indoor_temperature_value; - - /* - * Draw a data point if: - * It's one of the nth data points (smoothing) OR - * The previous value is null (forces data point right when null data stops instead of on the 10th) OR - * The current value is null (forces null data to display as a blank section) PR - * The next value is null (forces data point right when null data starts instead of on the 10th) - * The current value is the last value (forces data point right at the end) - */ - if ( - i % smoothing_factor === 0 || - ( - previous_indoor_temperature_value === null && - indoor_temperature_value !== null - ) || - indoor_temperature_value === null || - ( - beestat.cache.runtime_thermostat[i + 1] !== undefined && - beestat.cache.runtime_thermostat[i + 1].indoor_temperature === null - ) || - i === (beestat.cache.runtime_thermostat.length - 1) - ) { - series.indoor_temperature.enabled = true; - series.indoor_temperature.chart_data.push([ - x, - indoor_temperature_value - ]); - - if (indoor_temperature_value !== null) { - self.y_min_ = Math.min(self.y_min_, indoor_temperature_value); - self.y_max_ = Math.max(self.y_max_, indoor_temperature_value); - } - } - - // Outdoor temperature - var outdoor_temperature_value = beestat.temperature(runtime_thermostat.outdoor_temperature); - series.outdoor_temperature.data[x] = outdoor_temperature_value; - - /* - * Draw a data point if: - * It's one of the 10th data points (smoothing) OR - * The previous value is null (forces data point right when null data stops instead of on the 10th) OR - * The current value is null (forces null data to display as a blank section) PR - * The next value is null (forces data point right when null data starts instead of on the 10th) - * The current value is the last value (forces data point right at the end) - */ - if ( - i % smoothing_factor === 0 || - ( - previous_outdoor_temperature_value === null && - outdoor_temperature_value !== null - ) || - outdoor_temperature_value === null || - ( - beestat.cache.runtime_thermostat[i + 1] !== undefined && - beestat.cache.runtime_thermostat[i + 1].outdoor_temperature === null - ) || - i === (beestat.cache.runtime_thermostat.length - 1) - ) { - series.outdoor_temperature.enabled = true; - series.outdoor_temperature.chart_data.push([ - x, - outdoor_temperature_value - ]); - - if (outdoor_temperature_value !== null) { - self.y_min_ = Math.min(self.y_min_, outdoor_temperature_value); - self.y_max_ = Math.max(self.y_max_, outdoor_temperature_value); - } - } - - // Indoor humidity - var indoor_humidity_value; - if (runtime_thermostat.indoor_humidity !== null) { - indoor_humidity_value = parseInt( - runtime_thermostat.indoor_humidity, - 10 - ); - } else { - indoor_humidity_value = null; - } - series.indoor_humidity.data[x] = indoor_humidity_value; - - /* - * Draw a data point if: - * It's one of the 10th data points (smoothing) OR - * The previous value is null (forces data point right when null data stops instead of on the 10th) OR - * The current value is null (forces null data to display as a blank section) PR - * The next value is null (forces data point right when null data starts instead of on the 10th) - * The current value is the last value (forces data point right at the end) - */ - if ( - i % smoothing_factor === 0 || - ( - previous_indoor_humidity_value === null && - indoor_humidity_value !== null - ) || - indoor_humidity_value === null || - ( - beestat.cache.runtime_thermostat[i + 1] !== undefined && - beestat.cache.runtime_thermostat[i + 1].indoor_humidity === null - ) || - i === (beestat.cache.runtime_thermostat.length - 1) - ) { - series.indoor_humidity.enabled = true; - series.indoor_humidity.chart_data.push([ - x, - indoor_humidity_value - ]); - } - - // Outdoor humidity - var outdoor_humidity_value; - if (runtime_thermostat.outdoor_humidity !== null) { - outdoor_humidity_value = parseInt( - runtime_thermostat.outdoor_humidity, - 10 - ); - } else { - outdoor_humidity_value = null; - } - series.outdoor_humidity.data[x] = outdoor_humidity_value; - - /* - * Draw a data point if: - * It's one of the 10th data points (smoothing) OR - * The previous value is null (forces data point right when null data stops instead of on the 10th) OR - * The current value is null (forces null data to display as a blank section) PR - * The next value is null (forces data point right when null data starts instead of on the 10th) - * The current value is the last value (forces data point right at the end) - */ - if ( - i % smoothing_factor === 0 || - ( - previous_outdoor_humidity_value === null && - outdoor_humidity_value !== null - ) || - outdoor_humidity_value === null || - ( - beestat.cache.runtime_thermostat[i + 1] !== undefined && - beestat.cache.runtime_thermostat[i + 1].outdoor_humidity === null - ) || - i === (beestat.cache.runtime_thermostat.length - 1) - ) { - series.outdoor_humidity.enabled = true; - series.outdoor_humidity.chart_data.push([ - x, - outdoor_humidity_value - ]); - } - - previous_indoor_temperature_value = indoor_temperature_value; - previous_outdoor_temperature_value = outdoor_temperature_value; - previous_indoor_humidity_value = indoor_humidity_value; - previous_outdoor_humidity_value = outdoor_humidity_value; - }); - - return series; -}; - -/** - * Get the title of the card. - * - * @return {string} Title - */ -beestat.component.card.recent_activity.prototype.get_title_ = function() { - return 'Recent Activity'; -}; - -/** - * Get the subtitle of the card. - * - * @return {string} Subtitle - */ -beestat.component.card.recent_activity.prototype.get_subtitle_ = function() { - var s = (beestat.setting('recent_activity_time_count') > 1) ? 's' : ''; - - return 'Past ' + - beestat.setting('recent_activity_time_count') + - ' ' + - beestat.setting('recent_activity_time_period') + - s; -}; - -/** - * Determine whether or not enough data is currently available to render this - * card. In this particular case require data from 7 days to an hour ago to be synced. - * - * @return {boolean} Whether or not the data is available. - */ -beestat.component.card.recent_activity.prototype.data_available_ = function() { - // Demo can juse grab whatever data is there. - if (window.is_demo === true) { - return true; - } - - var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; - - var current_sync_begin = moment.utc(thermostat.sync_begin); - var current_sync_end = moment.utc(thermostat.sync_end); - - var required_sync_begin = moment().subtract(7, 'day'); - required_sync_begin = moment.max( - required_sync_begin, - moment(thermostat.first_connected) - ); - var required_sync_end = moment().subtract(1, 'hour'); - - return ( - current_sync_begin.isSameOrBefore(required_sync_begin) && - current_sync_end.isSameOrAfter(required_sync_end) - ); -}; - -/** - * Get the data needed to render this card. - */ -beestat.component.card.recent_activity.prototype.get_data_ = function() { - var self = this; - var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; - - new beestat.api() - .add_call( - 'runtime_thermostat', - 'read', - { - 'attributes': { - 'thermostat_id': thermostat.thermostat_id, - 'timestamp': { - 'value': moment() - .subtract(7, 'd') - .format('YYYY-MM-DD'), - 'operator': '>' - } - } - } - ) - .set_callback(function(response) { - beestat.cache.set('runtime_thermostat', response); - self.rerender(); - }) - .send(); -}; diff --git a/js/component/card/runtime_detail.js b/js/component/card/runtime_detail.js new file mode 100644 index 0000000..822d310 --- /dev/null +++ b/js/component/card/runtime_detail.js @@ -0,0 +1,835 @@ +/** + * Runtime detail card. Shows a graph similar to what ecobee shows with the + * runtime info for a recent period of time. + * + * @param {number} thermostat_id The thermostat_id this card is displaying + * data for + */ +beestat.component.card.runtime_detail = function(thermostat_id) { + var self = this; + + this.thermostat_id_ = thermostat_id; + + /* + * When a setting is changed clear all of the data. Then rerender which will + * trigger the loading state. Also do this when the cache changes. + * + * Debounce so that multiple setting changes don't re-trigger the same + * event. This fires on the trailing edge so that all changes are accounted + * for when rerendering. + */ + var change_function = beestat.debounce(function() { + self.rerender(); + }, 10); + + beestat.dispatcher.addEventListener( + [ + 'setting.runtime_detail_smoothing', + 'setting.runtime_detail_range_type', + 'setting.runtime_detail_range_dynamic', + 'cache.runtime_thermostat' + ], + change_function + ); + + beestat.component.card.apply(this, arguments); +}; +beestat.extend(beestat.component.card.runtime_detail, beestat.component.card); + +/** + * Decorate + * + * @param {rocket.ELements} parent + */ +beestat.component.card.runtime_detail.prototype.decorate_contents_ = function(parent) { + var self = this; + + var data = this.get_data_(); + this.chart_ = new beestat.component.chart.runtime_detail(data); + this.chart_.render(parent); + + var thermostat = beestat.cache.thermostat[this.thermostat_id_]; + + var required_begin; + var required_end; + if (beestat.setting('runtime_detail_range_type') === 'dynamic') { + required_begin = moment() + .subtract( + beestat.setting('runtime_detail_range_dynamic'), + 'day' + ) + .second(0); + + required_end = moment() + .subtract(1, 'hour') + .second(0); + } else { + required_begin = moment( + beestat.setting('runtime_detail_range_static_begin') + ' 00:00:00' + ); + required_end = moment( + beestat.setting('runtime_detail_range_static_end') + ' 23:59:59' + ); + } + + required_begin = moment.max( + required_begin, + moment(thermostat.first_connected) + ); + + /** + * If the needed data exists in the database and the runtime_thermostat + * cache is empty, then query the data. If the needed data does not exist in + * the database, check every 2 seconds until it does. + */ + if (this.data_synced_(required_begin, required_end) === true) { + if (beestat.cache.runtime_thermostat === undefined) { + this.show_loading_('Loading Runtime Detail'); + + var value; + var operator; + + if (beestat.setting('runtime_detail_range_type') === 'dynamic') { + value = required_begin.format(); + operator = '>='; + } else { + value = [ + required_begin.format(), + required_end.format() + ]; + operator = 'between'; + } + + new beestat.api() + .add_call( + 'runtime_thermostat', + 'read', + { + 'attributes': { + 'thermostat_id': thermostat.thermostat_id, + 'timestamp': { + 'value': value, + 'operator': operator + } + } + } + ) + .set_callback(function(response) { + beestat.cache.set('runtime_thermostat', response); + }) + .send(); + } + } else { + this.show_loading_('Syncing Runtime Detail'); + setTimeout(function() { + new beestat.api() + .add_call( + 'thermostat', + 'read_id', + {}, + 'thermostat' + ) + .set_callback(function(response) { + beestat.cache.set('thermostat', response); + self.rerender(); + }) + .send(); + }, 2000); + } +}; + +/** + * Decorate the menu + * + * @param {rocket.Elements} parent + */ +beestat.component.card.runtime_detail.prototype.decorate_top_right_ = function(parent) { + var self = this; + + var menu = (new beestat.component.menu()).render(parent); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 1 Day') + .set_icon('numeric_1_box') + .set_callback(function() { + if ( + beestat.setting('runtime_detail_range_dynamic') !== 1 || + beestat.setting('runtime_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('runtime_thermostat'); + beestat.setting({ + 'runtime_detail_range_dynamic': 1, + 'runtime_detail_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 3 Days') + .set_icon('numeric_3_box') + .set_callback(function() { + if ( + beestat.setting('runtime_detail_range_dynamic') !== 3 || + beestat.setting('runtime_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('runtime_thermostat'); + beestat.setting({ + 'runtime_detail_range_dynamic': 3, + 'runtime_detail_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Past 7 Days') + .set_icon('numeric_7_box') + .set_callback(function() { + if ( + beestat.setting('runtime_detail_range_dynamic') !== 7 || + beestat.setting('runtime_detail_range_type') !== 'dynamic' + ) { + beestat.cache.delete('runtime_thermostat'); + beestat.setting({ + 'runtime_detail_range_dynamic': 7, + 'runtime_detail_range_type': 'dynamic' + }); + } + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Custom') + .set_icon('calendar_edit') + .set_callback(function() { + (new beestat.component.modal.runtime_detail_custom()).render(); + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Download Chart') + .set_icon('download') + .set_callback(function() { + self.chart_.export(); + })); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Reset Zoom') + .set_icon('magnify_minus') + .set_callback(function() { + self.chart_.reset_zoom(); + })); + + if (beestat.setting('runtime_detail_smoothing') === true) { + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Disable Smothing') + .set_icon('chart_line') + .set_callback(function() { + beestat.setting('runtime_detail_smoothing', false); + })); + } else { + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Enable Smoothing') + .set_icon('chart_bell_curve') + .set_callback(function() { + beestat.setting('runtime_detail_smoothing', true); + })); + } + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Help') + .set_icon('help_circle') + .set_callback(function() { + (new beestat.component.modal.help_runtime_detail()).render(); + })); +}; + +/** + * Get all of the series data. + * + * @return {object} The series data. + */ +beestat.component.card.runtime_detail.prototype.get_data_ = function() { + var data = { + 'x': [], + 'series': {}, + 'metadata': { + 'series': {}, + 'chart': { + 'title': this.get_title_(), + 'subtitle': this.get_subtitle_(), + 'y_min': Infinity, + 'y_max': -Infinity + } + } + }; + + // A couple private helper functions for manipulating the min/max y values. + var y_min_max = function(value) { + if (value !== null) { + data.metadata.chart.y_min = Math.min(data.metadata.chart.y_min, value); + data.metadata.chart.y_max = Math.max(data.metadata.chart.y_max, value); + } + }; + + // Duration objects. These are passed by reference into the metadata. + var durations = {}; + + // Y values for equipment swimlane data. + var equipment_y = { + 'calendar_event_smartrecovery': 94, + 'calendar_event_home': 94, + 'calendar_event_away': 94, + 'calendar_event_sleep': 94, + 'calendar_event_smarthome': 94, + 'calendar_event_smartaway': 94, + 'calendar_event_hold': 94, + 'calendar_event_vacation': 94, + 'calendar_event_quicksave': 94, + 'calendar_event_other': 94, + 'calendar_event_custom': 94, + 'compressor_heat_1': 67, + 'compressor_heat_2': 67, + 'auxiliary_heat_1': 67, + 'auxiliary_heat_2': 67, + 'compressor_cool_1': 67, + 'compressor_cool_2': 67, + 'fan': 47, + 'humidifier': 31, + 'dehumidifier': 31, + 'ventilator': 31, + 'economizer': 31 + }; + + // Initialize a bunch of stuff. + [ + 'calendar_event_smartrecovery', + 'calendar_event_home', + 'calendar_event_away', + 'calendar_event_sleep', + 'calendar_event_smarthome', + 'calendar_event_smartaway', + 'calendar_event_hold', + 'calendar_event_vacation', + 'calendar_event_quicksave', + 'calendar_event_other', + 'calendar_event_custom', + 'outdoor_temperature', + 'indoor_temperature', + 'indoor_humidity', + 'outdoor_humidity', + 'setpoint_heat', + 'setpoint_cool', + 'fan', + 'compressor_heat_1', + 'compressor_heat_2', + 'auxiliary_heat_1', + 'auxiliary_heat_2', + 'compressor_cool_1', + 'compressor_cool_2', + 'humidifier', + 'dehumidifier', + 'ventilator', + 'economizer', + 'dummy' + ].forEach(function(series_code) { + data.series[series_code] = []; + data.metadata.series[series_code] = { + 'active': false, + 'durations': {} + }; + durations[series_code] = {'seconds': 0}; + }); + + data.metadata.series.calendar_event_name = {}; + data.metadata.series.system_mode = {}; + + /* + * Figure out what date range to use. + * var begin_m = moment() + * .subtract( + * beestat.setting('runtime_detail_range_dynamic'), + * 'day' + * ); + * begin_m + * .minute(Math.ceil(begin_m.minute() / 5) * 5) + * .second(0) + * .millisecond(0); + * var end_m = moment(); + */ + + var begin_m; + var end_m; + if (beestat.setting('runtime_detail_range_type') === 'dynamic') { + begin_m = moment().subtract( + beestat.setting('runtime_detail_range_dynamic'), + 'day' + ); + end_m = moment().subtract(1, 'hour'); + } else { + begin_m = moment( + beestat.setting('runtime_detail_range_static_begin') + ' 00:00:00' + ); + end_m = moment( + beestat.setting('runtime_detail_range_static_end') + ' 23:59:59' + ); + } + + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + begin_m = moment.max( + begin_m, + moment(thermostat.first_connected) + ); + + begin_m + .minute(Math.ceil(begin_m.minute() / 5) * 5) + .second(0) + .millisecond(0); + + var runtime_thermostats = this.get_runtime_thermostat_by_date_(); + + // Initialize moving average. + var moving = []; + var moving_count; + if (beestat.setting('runtime_detail_smoothing') === true) { + moving_count = 15; + } else { + moving_count = 1; + } + var offset; + for (var i = 0; i < moving_count; i++) { + offset = (i - Math.floor(moving_count / 2)) * 300000; + moving.push(runtime_thermostats[begin_m.valueOf() + offset]); + } + + // Loop. + var current_m = begin_m; + while ( + // beestat.cache.runtime_thermostat.length > 0 && + current_m.isSameOrAfter(end_m) === false + ) { + data.x.push(current_m.clone()); + + // Without this series the chart will jump to the nearest value if there is a chunk of missing data. + data.series.dummy.push(1); + data.metadata.series.dummy.active = true; + + var runtime_thermostat = runtime_thermostats[ + current_m.valueOf() + ]; + + if (runtime_thermostat !== undefined) { + /** + * Things that use the moving average. + */ + var indoor_humidity_moving = this.get_average_(moving, 'indoor_humidity'); + data.series.indoor_humidity.push(indoor_humidity_moving); + data.metadata.series.indoor_humidity.active = true; + + var outdoor_humidity_moving = this.get_average_(moving, 'outdoor_humidity'); + data.series.outdoor_humidity.push(outdoor_humidity_moving); + data.metadata.series.outdoor_humidity.active = true; + + var indoor_temperature_moving = this.get_average_(moving, 'indoor_temperature'); + data.series.indoor_temperature.push(indoor_temperature_moving); + y_min_max(indoor_temperature_moving); + data.metadata.series.indoor_temperature.active = true; + + var outdoor_temperature_moving = this.get_average_(moving, 'outdoor_temperature'); + data.series.outdoor_temperature.push(outdoor_temperature_moving); + y_min_max(outdoor_temperature_moving); + data.metadata.series.outdoor_temperature.active = true; + + /** + * Add setpoints, but only when relevant. For example: Only show the + * heat setpoint line when the heat is actually on. + */ + if ( + runtime_thermostat.system_mode === 'auto' || + runtime_thermostat.system_mode === 'heat' || + runtime_thermostat.system_mode === 'auxiliary_heat' + ) { + data.series.setpoint_heat.push( + beestat.temperature(runtime_thermostat.setpoint_heat) + ); + + data.metadata.series.setpoint_heat.active = true; + + y_min_max(runtime_thermostat.setpoint_heat); + } else { + data.series.setpoint_heat.push(null); + } + + if ( + runtime_thermostat.system_mode === 'auto' || + runtime_thermostat.system_mode === 'cool' + ) { + data.series.setpoint_cool.push( + beestat.temperature(runtime_thermostat.setpoint_cool) + ); + + data.metadata.series.setpoint_cool.active = true; + + y_min_max(runtime_thermostat.setpoint_cool); + } else { + data.series.setpoint_cool.push(null); + } + + /* + * HVAC Mode. This isn't graphed but it's available for the tooltip. + * series.system_mode.chart_data.push([x, runtime_thermostat.system_mode]); + */ + data.metadata.series.system_mode[current_m.valueOf()] = runtime_thermostat.system_mode; + + /* + * Thanks, ecobee...I more or less copied this code from the ecobee Follow + * Me graph to make sure it's accurate. + */ + var this_calendar_event; + var this_calendar_event_name; + + if (runtime_thermostat.event === null) { + if (runtime_thermostat.climate === null) { + this_calendar_event = 'calendar_event_other'; + this_calendar_event_name = 'Other'; + } else { + switch (runtime_thermostat.climate.toLowerCase()) { + case 'home': + case 'sleep': + case 'away': + this_calendar_event = 'calendar_event_' + runtime_thermostat.climate.toLowerCase(); + this_calendar_event_name = runtime_thermostat.climate; + break; + default: + this_calendar_event = 'calendar_event_custom'; + this_calendar_event_name = runtime_thermostat.climate; + break; + } + } + } else if (runtime_thermostat.event.match(/SmartRecovery/i) !== null) { + this_calendar_event = 'calendar_event_smartrecovery'; + this_calendar_event_name = 'Smart Recovery'; + } else if (runtime_thermostat.event.match(/^home$/i) !== null) { + this_calendar_event = 'calendar_event_home'; + this_calendar_event_name = 'Home'; + } else if (runtime_thermostat.event.match(/^away$/i) !== null) { + this_calendar_event = 'calendar_event_away'; + this_calendar_event_name = 'Away'; + } else if (runtime_thermostat.event.match(/^smarthome$/i) !== null) { + this_calendar_event = 'calendar_event_smarthome'; + this_calendar_event_name = 'Smart Home'; + } else if (runtime_thermostat.event.match(/^smartaway$/i) !== null) { + this_calendar_event = 'calendar_event_smartaway'; + this_calendar_event_name = 'Smart Away'; + } else if ( + runtime_thermostat.event.match(/^auto$/i) !== null || + runtime_thermostat.event.match(/^today$/i) !== null || + runtime_thermostat.event.match(/^hold$/i) !== null + ) { + this_calendar_event = 'calendar_event_hold'; + this_calendar_event_name = 'Hold'; + } else if ( + runtime_thermostat.event.match(/^vacation$/i) !== null || + runtime_thermostat.event.match(/(\S\S\S\s\d+\s\d\d\d\d)|(\d{12})/i) !== null + ) { + this_calendar_event = 'calendar_event_vacation'; + this_calendar_event_name = 'Vacation'; + } else if (runtime_thermostat.event.match(/^quicksave$/i) !== null) { + this_calendar_event = 'calendar_event_quicksave'; + this_calendar_event_name = 'Quick Save'; + } else { + this_calendar_event = 'calendar_event_other'; + this_calendar_event_name = 'Other'; + } + + [ + 'calendar_event_smartrecovery', + 'calendar_event_home', + 'calendar_event_away', + 'calendar_event_sleep', + 'calendar_event_smarthome', + 'calendar_event_smartaway', + 'calendar_event_hold', + 'calendar_event_vacation', + 'calendar_event_quicksave', + 'calendar_event_other', + 'calendar_event_custom' + ].forEach(function(calendar_event) { + if (calendar_event === this_calendar_event) { + data.series[calendar_event].push(equipment_y[calendar_event]); + data.metadata.series[calendar_event].active = true; + } else { + data.series[calendar_event].push(null); + } + }); + + data.metadata.series.calendar_event_name[current_m.valueOf()] = + this_calendar_event_name; + + /** + * If all stages of the compressor are off, clear the durations. It is + * important that this only get reset if the seconds values are also + * zero to support backfilling. + */ + if ( + runtime_thermostat.compressor_1 === 0 && + runtime_thermostat.compressor_2 === 0 && + ( + durations.compressor_heat_1.seconds > 0 || + durations.compressor_heat_2.seconds > 0 || + durations.compressor_cool_1.seconds > 0 || + durations.compressor_cool_2.seconds > 0 + ) + ) { + durations.compressor_heat_1 = {'seconds': 0}; + durations.compressor_heat_2 = {'seconds': 0}; + durations.compressor_cool_1 = {'seconds': 0}; + durations.compressor_cool_2 = {'seconds': 0}; + } + + if ( + runtime_thermostat.auxiliary_heat_1 === 0 && + runtime_thermostat.auxiliary_heat_2 === 0 && + ( + durations.auxiliary_heat_1.seconds > 0 || + durations.auxiliary_heat_2.seconds > 0 + ) + ) { + durations.auxiliary_heat_1 = {'seconds': 0}; + durations.auxiliary_heat_2 = {'seconds': 0}; + } + + // Reset fan to 0 + if (runtime_thermostat.fan === 0) { + durations.fan = {'seconds': 0}; + } + + // Reset accessories + if (runtime_thermostat.accessory === 0) { + durations[runtime_thermostat.accessory_type] = {'seconds': 0}; + } + + // Equipment + [ + 'fan', + 'compressor_heat_1', + 'compressor_heat_2', + 'auxiliary_heat_1', + 'auxiliary_heat_2', + 'compressor_cool_1', + 'compressor_cool_2', + 'humidifier', + 'dehumidifier', + 'ventilator', + 'economizer' + ].forEach(function(series_code) { + var runtime_thermostat_series_code; + switch (series_code) { + case 'compressor_heat_1': + case 'compressor_heat_2': + runtime_thermostat_series_code = series_code + .replace('compressor_heat', 'compressor'); + break; + case 'compressor_cool_1': + case 'compressor_cool_2': + runtime_thermostat_series_code = series_code + .replace('compressor_cool', 'compressor'); + break; + case 'humidifier': + case 'dehumidifier': + case 'ventilator': + case 'economizer': + runtime_thermostat_series_code = 'accessory'; + break; + default: + runtime_thermostat_series_code = series_code; + break; + } + + var equipment_on = function(series_code_on, runtime_thermostat_series_code_on) { + switch (series_code_on) { + case 'compressor_heat_1': + case 'compressor_heat_2': + return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && + runtime_thermostat.compressor_mode === 'heat'; + case 'compressor_cool_1': + case 'compressor_cool_2': + return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && + runtime_thermostat.compressor_mode === 'cool'; + case 'humidifier': + case 'dehumidifier': + case 'ventilator': + case 'economizer': + return runtime_thermostat[runtime_thermostat_series_code_on] > 0 && + runtime_thermostat.accessory_type === series_code; + default: + return runtime_thermostat[series_code] > 0; + } + }; + + if (equipment_on(series_code, runtime_thermostat_series_code) === true) { + data.metadata.series[series_code].active = true; + data.metadata.series[series_code].durations[current_m.valueOf()] = durations[series_code]; + data.series[series_code].push(equipment_y[series_code]); + + if ( + series_code === 'auxiliary_heat_1' || + series_code === 'compressor_heat_1' || + series_code === 'compressor_cool_1' + ) { + var series_code_2 = series_code.replace('1', '2'); + data.metadata.series[series_code_2].durations[current_m.valueOf()] = durations[series_code_2]; + } + durations[series_code].seconds += runtime_thermostat[runtime_thermostat_series_code]; + + /* + * If heat/cool/aux 2 is on, extend the bar from heat/cool/aux 1 + * behind and set the duration. + */ + if (series_code.slice(-1) === '2') { + var series_code_1 = series_code.replace('2', '1'); + data.series[series_code_1] + .splice(-1, 1, equipment_y[series_code_1]); + data.metadata.series[series_code_1] + .durations[current_m.valueOf()] = durations[series_code_1]; + } + } else { + data.series[series_code].push(null); + } + }); + } else { + data.series.calendar_event_smartrecovery.push(null); + data.series.calendar_event_home.push(null); + data.series.calendar_event_away.push(null); + data.series.calendar_event_sleep.push(null); + data.series.calendar_event_smarthome.push(null); + data.series.calendar_event_smartaway.push(null); + data.series.calendar_event_hold.push(null); + data.series.calendar_event_vacation.push(null); + data.series.calendar_event_quicksave.push(null); + data.series.calendar_event_other.push(null); + data.series.calendar_event_custom.push(null); + data.series.indoor_temperature.push(null); + data.series.outdoor_temperature.push(null); + data.series.indoor_humidity.push(null); + data.series.outdoor_humidity.push(null); + data.series.setpoint_heat.push(null); + data.series.setpoint_cool.push(null); + data.series.fan.push(null); + data.series.compressor_heat_1.push(null); + data.series.compressor_heat_2.push(null); + data.series.auxiliary_heat_1.push(null); + data.series.auxiliary_heat_2.push(null); + data.series.compressor_cool_1.push(null); + data.series.compressor_cool_2.push(null); + data.series.humidifier.push(null); + data.series.dehumidifier.push(null); + data.series.ventilator.push(null); + data.series.economizer.push(null); + } + + current_m.add(5, 'minute'); + + /** + * Remove the first row in the moving average and add the next one. Yes + * this could introduce undefined values; that's ok. Those are handled in + * the get_average_ function. + */ + moving.shift(); + moving.push(runtime_thermostats[current_m.valueOf() + offset]); + } + + return data; +}; + +/** + * Get all the runtime_thermostat rows indexed by date. + * + * @return {array} The runtime_thermostat rows. + */ +beestat.component.card.runtime_detail.prototype.get_runtime_thermostat_by_date_ = function() { + var runtime_thermostats = {}; + if (beestat.cache.runtime_thermostat !== undefined) { + beestat.cache.runtime_thermostat.forEach(function(runtime_thermostat) { + runtime_thermostats[moment(runtime_thermostat.timestamp).valueOf()] = runtime_thermostat; + }); + } + return runtime_thermostats; +}; + +/** + * Given an array of runtime thermostats, get the average value of one of the + * keys. Allows and ignores undefined values in order to keep a more accurate + * moving average. + * + * @param {array} runtime_thermostats + * @param {string} series_code + * + * @return {number} The average. + */ +beestat.component.card.runtime_detail.prototype.get_average_ = function(runtime_thermostats, series_code) { + var average = 0; + var count = 0; + for (var i = 0; i < runtime_thermostats.length; i++) { + if (runtime_thermostats[i] !== undefined) { + average += runtime_thermostats[i][series_code]; + count++; + } + } + return average / count; +}; + +/** + * Get the title of the card. + * + * @return {string} Title + */ +beestat.component.card.runtime_detail.prototype.get_title_ = function() { + return 'Runtime Detail'; +}; + +/** + * Get the subtitle of the card. + * + * @return {string} Subtitle + */ +beestat.component.card.runtime_detail.prototype.get_subtitle_ = function() { + if (beestat.setting('runtime_detail_range_type') === 'dynamic') { + var s = (beestat.setting('runtime_detail_range_dynamic') > 1) ? 's' : ''; + + return 'Past ' + + beestat.setting('runtime_detail_range_dynamic') + + ' day' + + s; + } + + var begin = moment(beestat.setting('runtime_detail_range_static_begin')) + .format('MMM D, YYYY'); + var end = moment(beestat.setting('runtime_detail_range_static_end')) + .format('MMM D, YYYY'); + + return begin + ' to ' + end; +}; + +/** + * Determine whether or not the data to render the desired date range has been + * synced. + * + * @param {moment} required_sync_begin + * @param {moment} required_sync_end + * + * @return {boolean} Whether or not the data is synced. + */ +beestat.component.card.runtime_detail.prototype.data_synced_ = function(required_sync_begin, required_sync_end) { + // Demo can just grab whatever data is there. + if (window.is_demo === true) { + return true; + } + + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + + var current_sync_begin = moment.utc(thermostat.sync_begin); + var current_sync_end = moment.utc(thermostat.sync_end); + + return ( + current_sync_begin.isSameOrBefore(required_sync_begin) && + current_sync_end.isSameOrAfter(required_sync_end) + ); +}; diff --git a/js/component/card/runtime_thermostat_summary.js b/js/component/card/runtime_thermostat_summary.js index 1a26f90..dff3712 100755 --- a/js/component/card/runtime_thermostat_summary.js +++ b/js/component/card/runtime_thermostat_summary.js @@ -50,7 +50,7 @@ beestat.extend(beestat.component.card.runtime_thermostat_summary, beestat.compon */ beestat.component.card.runtime_thermostat_summary.prototype.decorate_contents_ = function(parent) { var data = this.get_data_(); - this.chart_ = new beestat.component.chart2.runtime_thermostat_summary(data); + this.chart_ = new beestat.component.chart.runtime_thermostat_summary(data); this.chart_.render(parent); var sync_progress = beestat.get_sync_progress(this.thermostat_id_); diff --git a/js/component/card/temperature_profiles.js b/js/component/card/temperature_profiles.js index cefa694..d83b520 100644 --- a/js/component/card/temperature_profiles.js +++ b/js/component/card/temperature_profiles.js @@ -18,7 +18,7 @@ beestat.extend(beestat.component.card.temperature_profiles, beestat.component.ca */ beestat.component.card.temperature_profiles.prototype.decorate_contents_ = function(parent) { var data = this.get_data_(); - this.chart_ = new beestat.component.chart2.temperature_profiles(data); + this.chart_ = new beestat.component.chart.temperature_profiles(data); this.chart_.render(parent); }; diff --git a/js/component/chart.js b/js/component/chart.js index eef4ef1..a13d489 100644 --- a/js/component/chart.js +++ b/js/component/chart.js @@ -5,22 +5,216 @@ beestat.component.chart = function() { var self = this; - this.options = {}; + this.addEventListener('render', function() { + self.chart_.reflow(); + }); - this.options.credits = false; + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.chart, beestat.component); - this.options.exporting = { +beestat.component.chart.prototype.rerender_on_breakpoint_ = false; + +/** + * Decorate. Calls all the option getters and renders the chart. + * + * @param {rocket.Elements} parent + */ +beestat.component.chart.prototype.decorate_ = function(parent) { + var options = {}; + + options.credits = this.get_options_credits_(); + options.exporting = this.get_options_exporting_(); + options.chart = this.get_options_chart_(); + options.title = this.get_options_title_(); + options.subtitle = this.get_options_subtitle_(); + options.legend = this.get_options_legend_(); + options.plotOptions = this.get_options_plotOptions_(); + options.xAxis = this.get_options_xAxis_(); + options.yAxis = this.get_options_yAxis_(); + options.series = this.get_options_series_(); + options.tooltip = this.get_options_tooltip_(); + + options.chart.renderTo = parent[0]; + + this.chart_ = Highcharts.chart(options); +}; + +/** + * Reset the chart zoom level all the way out. + */ +beestat.component.chart.prototype.reset_zoom = function() { + this.chart_.zoomOut(); +}; + +/** + * Export the chart to a PNG. + */ +beestat.component.chart.prototype.export = function() { + this.chart_.exportChartLocal(); +}; + +/** + * Get the legend options. + * + * @return {object} The legend options. + */ +beestat.component.chart.prototype.get_options_legend_ = function() { + return { + 'itemStyle': { + 'color': '#ecf0f1', + 'font-weight': '500' + }, + 'itemHoverStyle': { + 'color': '#bdc3c7' + }, + 'itemHiddenStyle': { + 'color': '#7f8c8d' + }, + 'labelFormatter': this.get_options_legend_labelFormatter_() + }; +}; + +/** + * Get the legend labelFormatter options. + * + * @return {Function} The legend labelFormatter options. + */ +beestat.component.chart.prototype.get_options_legend_labelFormatter_ = function() { + return function() { + return beestat.series[this.name].name; + }; +}; + +/** + * Get the plotOptions. + * + * @return {object} The plotOptions. + */ +beestat.component.chart.prototype.get_options_plotOptions_ = function() { + return { + 'series': { + 'animation': false, + 'marker': { + 'enabled': false + }, + 'states': { + 'hover': { + 'enabled': false + }, + 'inactive': { + 'opacity': 1 + } + }, + 'connectNulls': this.get_options_plotOptions_series_connectNulls_() + }, + 'column': { + 'pointPadding': 0, + 'borderWidth': 0, + 'stacking': 'normal', + 'dataLabels': { + 'enabled': false + } + } + }; +}; + +/** + * Get whether or not to connect nulls. + * + * @return {boolean} Whether or not to connect nulls. + */ +beestat.component.chart.prototype.get_options_plotOptions_series_connectNulls_ = function() { + return false; +}; + +/** + * Get the title options. + * + * @return {object} The title options. + */ +beestat.component.chart.prototype.get_options_title_ = function() { + return { + 'text': null + }; +}; + +/** + * Get the subtitle options + * + * @return {object} The subtitle options. + */ +beestat.component.chart.prototype.get_options_subtitle_ = function() { + return { + 'text': null + }; +}; + +/** + * Get the chart options. + * + * @return {object} The chart options. + */ +beestat.component.chart.prototype.get_options_chart_ = function() { + return { + 'style': { + 'fontFamily': 'Montserrat' + }, + 'spacing': [ + beestat.style.size.gutter, + 0, + 0, + 0 + ], + 'zoomType': this.get_options_chart_zoomType_(), + 'panning': true, + 'panKey': 'ctrl', + 'backgroundColor': beestat.style.color.bluegray.base, + 'resetZoomButton': { + 'theme': { + 'display': 'none' + } + }, + 'height': this.get_options_chart_height_() + }; +}; + +/** + * Get the height of the chart. + * + * @return {number} The height of the chart. + */ +beestat.component.chart.prototype.get_options_chart_height_ = function() { + return null; +}; + +/** + * Get the zoomType option. Return null for no zoom. + * + * @return {string} The zoom type. + */ +beestat.component.chart.prototype.get_options_chart_zoomType_ = function() { + return 'x'; +}; + +/** + * Get the export options. + * + * @return {object} The export options. + */ +beestat.component.chart.prototype.get_options_exporting_ = function() { + return { 'enabled': false, 'sourceWidth': 980, 'scale': 1, - 'filename': 'beestat', + 'filename': this.get_options_exporting_filename_(), 'chartOptions': { 'credits': { 'text': 'beestat.io' }, 'title': { 'align': 'left', - 'text': null, + 'text': this.get_options_exporting_chartOptions_title_text_(), 'margin': beestat.style.size.gutter, 'style': { 'color': '#fff', @@ -30,7 +224,7 @@ beestat.component.chart = function() { }, 'subtitle': { 'align': 'left', - 'text': null, + 'text': this.get_options_exporting_chartOptions_subtitle_text_(), 'style': { 'color': '#fff', 'font-weight': beestat.style.font_weight.light, @@ -50,149 +244,168 @@ beestat.component.chart = function() { } } }; - - this.options.chart = { - 'style': { - 'fontFamily': 'Montserrat' - }, - 'spacing': [ - beestat.style.size.gutter, - 0, - 0, - 0 - ], - 'zoomType': 'x', - 'panning': true, - 'panKey': 'ctrl', - 'backgroundColor': beestat.style.color.bluegray.base, - 'resetZoomButton': { - 'theme': { - 'display': 'none' - } - } - }; - - this.options.title = { - 'text': null - }; - this.options.subtitle = { - 'text': null - }; - - this.options.legend = { - 'itemStyle': { - 'color': '#ecf0f1', - 'font-weight': '500' - }, - 'itemHoverStyle': { - 'color': '#bdc3c7' - }, - 'itemHiddenStyle': { - 'color': '#7f8c8d' - } - }; - - this.options.plotOptions = { - 'series': { - 'animation': false, - 'marker': { - 'enabled': false - }, - 'states': { - 'hover': { - 'enabled': false - }, - 'inactive': { - 'opacity': 1 - } - } - }, - 'column': { - 'pointPadding': 0, - 'borderWidth': 0, - 'stacking': 'normal', - 'dataLabels': { - 'enabled': false - } - } - }; - - this.addEventListener('render', function() { - self.chart_.reflow(); - }); - - beestat.component.apply(this, arguments); -}; -beestat.extend(beestat.component.chart, beestat.component); - -beestat.component.chart.prototype.rerender_on_breakpoint_ = false; - -beestat.component.chart.prototype.decorate_ = function(parent) { - this.options.chart.renderTo = parent[0]; - this.chart_ = Highcharts.chart(this.options); - - // parent.style('position', 'relative'); }; /** - * Get the Highcharts chart object + * Get the exported chart title. * - * @return {object} + * @return {string} The exported chart title. */ -beestat.component.chart.prototype.get_chart = function() { - return this.chart_; +beestat.component.chart.prototype.get_options_exporting_chartOptions_title_text_ = function() { + return this.data_.metadata.chart.title; }; /** - * Generate a number of colors between two points. + * Get the exported chart subtitle. * - * @param {Object} begin RGB begin color - * @param {Object} end RGB end color - * @param {number} steps Number of colors to generate - * - * @see http://forums.codeguru.com/showthread.php?259953-Code-to-create-Color-Gradient-programatically&s=4710043a327ee6059da1f8433ad1e5d2&p=795289#post795289 - * - * @private - * - * @return {Array.} RGB color array + * @return {string} The exported chart subtitle. */ -beestat.component.chart.generate_gradient = function(begin, end, steps) { - var gradient = []; - for (var i = 0; i < steps; i++) { - var n = i / (steps - 1); - gradient.push({ - 'r': Math.round(begin.r * (1 - n) + end.r * n), - 'g': Math.round(begin.g * (1 - n) + end.g * n), - 'b': Math.round(begin.b * (1 - n) + end.b * n) - }); - } - return gradient; +beestat.component.chart.prototype.get_options_exporting_chartOptions_subtitle_text_ = function() { + return this.data_.metadata.chart.subtitle; }; -beestat.component.chart.tooltip_positioner = function( - chart, - tooltip_width, - tooltip_height, - point -) { - var plot_width = chart.plotWidth; +/** + * Get the exported chart filename. + * + * @return {string} The exported chart filename. + */ +beestat.component.chart.prototype.get_options_exporting_filename_ = function() { + var title = this.get_options_exporting_chartOptions_title_text_(); + var subtitle = this.get_options_exporting_chartOptions_subtitle_text_(); - var fits_on_left = (point.plotX - tooltip_width) > 0; - var fits_on_right = (point.plotX + tooltip_width) < plot_width; - - var x; - var y = 60; - if (fits_on_left === true) { - x = point.plotX - tooltip_width + chart.plotLeft; - } else if (fits_on_right === true) { - x = point.plotX + chart.plotLeft; - } else { - x = chart.plotLeft; + var filename = []; + if (title !== null) { + filename.push(title); } + if (subtitle !== null) { + filename.push('-'); + filename.push(subtitle); + } + + if (filename.length === 0) { + filename.push('beestat'); + } + + return filename.join(' '); +}; + +/** + * Get the credits options. + * + * @return {boolean} The credits options. + */ +beestat.component.chart.prototype.get_options_credits_ = function() { + return false; +}; + +/** + * Get the xAxis options. + * + * @return {object} The xAxis options. + */ +beestat.component.chart.prototype.get_options_xAxis_ = function() { return { - 'x': x, - 'y': y + 'categories': this.data_.x, + 'lineColor': beestat.style.color.bluegray.light, + 'tickLength': 0, + 'labels': { + 'style': { + 'color': beestat.style.color.gray.base + }, + 'formatter': this.get_options_xAxis_labels_formatter_() + } + }; +}; + +/** + * Get the xAxis label formatter options. Needs to be overridden. + * + * @return {object} The xAxis label formatter options. + */ +beestat.component.chart.prototype.get_options_xAxis_labels_formatter_ = function() { + return null; +}; + +/** + * Get the yAxis label formatter options. Needs to be overridden. + * + * @return {object} The yAxis label formatter options. + */ +beestat.component.chart.prototype.get_options_yAxis_ = function() { + return null; +}; + +/** + * Get the series options. Needs to be overridden. + * + * @return {object} The series options. + */ +beestat.component.chart.prototype.get_options_series_ = function() { + return null; +}; + +/** + * Get the tooltip options. + * + * @return {object} The tooltip options. + */ +beestat.component.chart.prototype.get_options_tooltip_ = function() { + return { + 'shared': true, + 'useHTML': true, + 'borderWidth': 0, + 'shadow': false, + 'backgroundColor': null, + 'followPointer': true, + 'crosshairs': { + 'width': 1, + 'zIndex': 100, + 'color': beestat.style.color.gray.light, + 'dashStyle': 'shortDot', + 'snap': false + }, + 'positioner': this.get_options_tooltip_positioner_(), + 'formatter': this.get_options_tooltip_formatter_() + }; +}; + +/** + * Get the tooltip formatter. Needs to be overridden. + * + * @return {Function} The tooltip formatter. + */ +beestat.component.chart.prototype.get_options_tooltip_formatter_ = function() { + return null; +}; + +/** + * Get the tooltip positioner. Makes sure the tooltip is positioned nicely. + * + * @return {Function} The tooltip positioner. + */ +beestat.component.chart.prototype.get_options_tooltip_positioner_ = function() { + var self = this; + return function(tooltip_width, tooltip_height, point) { + var plot_width = self.chart_.plotWidth; + + var fits_on_left = (point.plotX - tooltip_width) > 0; + var fits_on_right = (point.plotX + tooltip_width) < plot_width; + + var x; + var y = 60; + if (fits_on_left === true) { + x = point.plotX - tooltip_width + self.chart_.plotLeft; + } else if (fits_on_right === true) { + x = point.plotX + self.chart_.plotLeft; + } else { + x = self.chart_.plotLeft; + } + + return { + 'x': x, + 'y': y + }; }; }; @@ -201,11 +414,10 @@ beestat.component.chart.tooltip_positioner = function( * * @param {string} title The tooltip title. * @param {array} sections Data inside the tooltip. - * @param {number} first_column_min_width Minimum width of the first column. * * @return {string} The tooltip HTML. */ -beestat.component.chart.tooltip_formatter = function(title, sections, first_column_min_width) { +beestat.component.chart.prototype.tooltip_formatter_helper_ = function(title, sections) { var tooltip = $.createElement('div') .style({ 'background-color': beestat.style.color.bluegray.dark, @@ -237,13 +449,15 @@ beestat.component.chart.tooltip_formatter = function(title, sections, first_colu var td_label = $.createElement('td') .style({ - 'min-width': (first_column_min_width || 115) + 'px', 'font-weight': beestat.style.font_weight.bold }) .innerText(item.label); tr.appendChild(td_label); - var td_value = $.createElement('td').innerText(item.value); + var td_value = $.createElement('td').innerText(item.value) + .style({ + 'padding-left': beestat.style.size.gutter / 4 + }); tr.appendChild(td_value); }); @@ -260,74 +474,3 @@ beestat.component.chart.tooltip_formatter = function(title, sections, first_colu return tooltip[0].outerHTML; }; - -beestat.component.chart.get_outdoor_temperature_zones = function() { - - /* - * This will get me one color for every degree on a nice gradient without - * using the multicolor series plugin. Very cool. - */ - var zone_definitions = [ - { - 'value': beestat.temperature(-20), - 'color': beestat.style.hex_to_rgb(beestat.style.color.lightblue.base) - }, - { - 'value': beestat.temperature(30), - 'color': beestat.style.hex_to_rgb(beestat.style.color.lightblue.base) - }, - { - 'value': beestat.temperature(60), - 'color': beestat.style.hex_to_rgb(beestat.style.color.green.base) - }, - { - 'value': beestat.temperature(75), - 'color': beestat.style.hex_to_rgb(beestat.style.color.yellow.base) - }, - { - 'value': beestat.temperature(90), - 'color': beestat.style.hex_to_rgb(beestat.style.color.red.base) - }, - { - 'value': beestat.temperature(120), - 'color': beestat.style.hex_to_rgb(beestat.style.color.red.base) - } - ]; - - var zones = []; - var zone_divisor = 1; // Increase this to like 2 or 3 if there are performance issues with this series. - for (var i = 0; i < zone_definitions.length - 1; i++) { - var gradient = beestat.component.chart.generate_gradient( - zone_definitions[i].color, - zone_definitions[i + 1].color, - Math.ceil((zone_definitions[i + 1].value - zone_definitions[i].value) / zone_divisor) - ); - for (var j = 0; j < gradient.length; j++) { - zones.push({ - 'value': zone_definitions[i].value + j, - 'color': 'rgb(' + gradient[j].r + ',' + gradient[j].g + ',' + gradient[j].b + ')' - }); - } - } - - return zones; -}; - -/** - * Wrap the highcharts SVG function with this to embed Montserrat 300 so the - * graph downloads don't look like garbage. - */ -Highcharts.wrap(Highcharts, 'downloadSVGLocal', function( - p, - svg, - options, - failCallback, - successCallback -) { - p( - svg.replace(/<\/svg/, '$&'), - options, - failCallback, - successCallback - ); -}); diff --git a/js/component/chart/runtime_detail.js b/js/component/chart/runtime_detail.js new file mode 100644 index 0000000..a07c167 --- /dev/null +++ b/js/component/chart/runtime_detail.js @@ -0,0 +1,420 @@ +/** + * Runtime thermostat summary chart. + * + * @param {object} data The chart data. + */ +beestat.component.chart.runtime_detail = function(data) { + this.data_ = data; + + beestat.component.chart.apply(this, arguments); +}; +beestat.extend(beestat.component.chart.runtime_detail, beestat.component.chart); + +/** + * Override for get_options_xAxis_labels_formatter_. + * + * @return {Function} xAxis labels formatter. + */ +beestat.component.chart.runtime_detail.prototype.get_options_xAxis_labels_formatter_ = function() { + var current_day; + var current_hour; + + return function() { + var hour = this.value.format('ha'); + var day = this.value.format('ddd'); + + var label_parts = []; + if (day !== current_day) { + label_parts.push(day); + } + if (hour !== current_hour) { + label_parts.push(hour); + } + + current_hour = hour; + current_day = day; + + return label_parts.join(' '); + }; +}; + +/** + * Override for get_options_series_. + * + * @return {Array} All of the series to display on the chart. + */ +beestat.component.chart.runtime_detail.prototype.get_options_series_ = function() { + var self = this; + var series = []; + + // Indoor/Outdoor Temperature + [ + 'indoor_temperature', + 'outdoor_temperature' + ].forEach(function(series_code) { + if (self.data_.metadata.series[series_code].active === true) { + series.push({ + 'name': series_code, + 'data': self.data_.series[series_code], + 'color': beestat.series[series_code].color, + 'yAxis': 0, + 'type': 'spline', + 'dashStyle': (series_code === 'indoor_temperature') ? 'Solid' : 'ShortDash', + 'lineWidth': (series_code === 'indoor_temperature') ? 2 : 1 + }); + } + }); + + // Setpoint Heat/Cool + [ + 'setpoint_heat', + 'setpoint_cool' + ].forEach(function(series_code) { + if (self.data_.metadata.series[series_code].active === true) { + series.push({ + 'name': series_code, + 'data': self.data_.series[series_code], + 'color': beestat.series[series_code].color, + 'yAxis': 0, + 'type': 'line', + 'lineWidth': 1, + 'step': 'right', + 'className': 'crisp_edges' + }); + } + }); + + // Indoor/Outdoor Humidity + [ + 'indoor_humidity', + 'outdoor_humidity' + ].forEach(function(series_code) { + if (self.data_.metadata.series[series_code].active === true) { + series.push({ + 'name': series_code, + 'data': self.data_.series[series_code], + 'color': beestat.series[series_code].color, + 'yAxis': 1, + 'type': 'spline', + 'dashStyle': (series_code === 'indoor_humidity') ? 'Solid' : 'ShortDash', + 'lineWidth': (series_code === 'indoor_humidity') ? 2 : 1, + 'visible': false + }); + } + }); + + // Swimlanes + [ + 'calendar_event_smartrecovery', + 'calendar_event_home', + 'calendar_event_away', + 'calendar_event_sleep', + 'calendar_event_smarthome', + 'calendar_event_smartaway', + 'calendar_event_hold', + 'calendar_event_vacation', + 'calendar_event_quicksave', + 'calendar_event_other', + 'calendar_event_custom', + 'compressor_heat_1', + 'compressor_heat_2', + 'auxiliary_heat_1', + 'auxiliary_heat_2', + 'compressor_cool_1', + 'compressor_cool_2', + 'fan', + 'humidifier', + 'dehumidifier', + 'ventilator', + 'economizer' + ].forEach(function(series_code) { + if (self.data_.metadata.series[series_code].active === true) { + var line_width; + if ( + series_code.includes('heat') === true || + series_code.includes('cool') === true + ) { + line_width = 12; + } else { + line_width = 6; + } + + series.push({ + 'name': series_code, + 'data': self.data_.series[series_code], + 'color': beestat.series[series_code].color, + 'yAxis': 2, + 'type': 'line', + 'lineWidth': line_width, + 'linecap': 'square', + 'className': 'crisp_edges', + 'showInLegend': false + }); + } + }); + + series.push({ + 'name': '', + 'data': self.data_.series.dummy, + 'yAxis': 2, + 'type': 'line', + 'lineWidth': 0, + 'showInLegend': false + }); + + return series; +}; + +/** + * Override for get_options_yAxis_. + * + * @return {Array} The y-axis options. + */ +beestat.component.chart.runtime_detail.prototype.get_options_yAxis_ = function() { + /** + * Highcharts doesn't seem to respect axis behavior well so just overriding + * it completely here. + */ + + var y_min = Math.floor((this.data_.metadata.chart.y_min - 5) / 10) * 10; + var y_max = Math.ceil((this.data_.metadata.chart.y_max + 10) / 10) * 10; + var tick_positions = []; + var tick_interval = (beestat.setting('temperature_unit') === '°F') ? 10 : 5; + var current_tick_position = + Math.floor(y_min / tick_interval) * tick_interval; + while (current_tick_position <= y_max) { + tick_positions.push(current_tick_position); + current_tick_position += tick_interval; + } + + return [ + // Temperature + { + 'height': '80%', + 'top': '20%', + 'gridLineColor': beestat.style.color.bluegray.light, + 'gridLineDashStyle': 'longdash', + 'title': {'text': null}, + 'labels': { + 'style': {'color': beestat.style.color.gray.base}, + 'formatter': function() { + return this.value + beestat.setting('temperature_unit'); + } + }, + 'tickPositions': tick_positions + }, + + // Humidity + { + 'height': '80%', + 'top': '20%', + 'alignTicks': false, + 'gridLineColor': null, + 'opposite': true, + 'title': {'text': null}, + 'labels': { + 'style': {'color': beestat.style.color.gray.base}, + 'formatter': function() { + return this.value + '%'; + } + }, + + // https://github.com/highcharts/highcharts/issues/3403 + 'min': 0, + 'minRange': 100, + 'ceiling': 100 + }, + + // Swimlanes + { + 'height': '20%', + 'top': '0%', + 'min': 0, + 'max': 100, + 'gridLineWidth': 0, + 'title': {'text': null}, + 'labels': {'enabled': false} + } + ]; +}; + +// https://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/stock/demo/candlestick-and-volume/ + +/** + * Override for get_options_tooltip_formatter_. + * + * @return {Function} The tooltip formatter. + */ +beestat.component.chart.runtime_detail.prototype.get_options_tooltip_formatter_ = function() { + var self = this; + + return function() { + var sections = []; + var groups = { + 'mode': [], + 'data': [], + 'equipment': [] + }; + + var values = {}; + this.points.forEach(function(point) { + values[point.series.name] = point.y; + }); + + // HVAC Mode + var system_mode; + var system_mode_color; + + switch (self.data_.metadata.series.system_mode[this.x.valueOf()]) { + case 'auto': + system_mode = 'Auto'; + system_mode_color = beestat.style.color.gray.base; + break; + case 'heat': + system_mode = 'Heat'; + system_mode_color = beestat.series.compressor_heat_1.color; + break; + case 'cool': + system_mode = 'Cool'; + system_mode_color = beestat.series.compressor_cool_1.color; + break; + case 'off': + system_mode = 'Off'; + system_mode_color = beestat.style.color.gray.base; + break; + case 'auxiliary_heat': + system_mode = 'Aux'; + system_mode_color = beestat.series.auxiliary_heat_1.color; + break; + } + + if (system_mode !== undefined) { + groups.mode.push({ + 'label': 'System Mode', + 'value': system_mode, + 'color': system_mode_color + }); + } + + this.points.forEach(function(point) { + var label; + var value; + var color; + var group; + + if ( + point.series.name.includes('temperature') === true || + point.series.name.includes('setpoint') === true + ) { + group = 'data'; + label = beestat.series[point.series.name].name; + color = point.series.color; + value = beestat.temperature({ + 'temperature': values[point.series.name], + 'convert': false, + 'units': true, + 'round': 0 + }); + } else if (point.series.name.includes('humidity') === true) { + group = 'data'; + label = beestat.series[point.series.name].name; + color = point.series.color; + value = Math.round(values[point.series.name]) + '%'; + } else if ( + point.series.name === 'fan' || + point.series.name === 'compressor_heat_1' || + point.series.name === 'auxiliary_heat_1' || + point.series.name === 'compressor_cool_1' || + point.series.name === 'dehumidifier' || + point.series.name === 'economizer' || + point.series.name === 'humidifier' || + point.series.name === 'ventilator' + ) { + group = 'equipment'; + label = beestat.series[point.series.name].name; + color = point.series.color; + value = beestat.time( + self.data_.metadata.series[point.series.name].durations[point.x.valueOf()] + ); + } else if ( + point.series.name.includes('calendar_event') + ) { + group = 'mode'; + label = 'Comfort Profile'; + color = point.series.color; + value = self.data_.metadata.series.calendar_event_name[point.x.valueOf()]; + } else { + return; + } + + groups[group].push({ + 'label': label, + 'value': value, + 'color': color + }); + + // Show stage 2 duration on stage 1, if applicable. + if ( + point.series.name === 'compressor_heat_1' && + self.data_.metadata.series.compressor_heat_2.durations[point.x.valueOf()].seconds > 0 + ) { + groups.equipment.push({ + 'label': beestat.series.compressor_heat_2.name, + 'value': beestat.time( + self.data_.metadata.series.compressor_heat_2.durations[point.x.valueOf()] + ), + 'color': beestat.series.compressor_heat_2.color + }); + } + + if ( + point.series.name === 'auxiliary_heat_1' && + self.data_.metadata.series.auxiliary_heat_2.durations[point.x.valueOf()].seconds > 0 + ) { + groups.equipment.push({ + 'label': beestat.series.auxiliary_heat_2.name, + 'value': beestat.time( + self.data_.metadata.series.auxiliary_heat_2.durations[point.x.valueOf()] + ), + 'color': beestat.series.auxiliary_heat_2.color + }); + } + + if ( + point.series.name === 'compressor_cool_1' && + self.data_.metadata.series.compressor_cool_2.durations[point.x.valueOf()].seconds > 0 + ) { + groups.equipment.push({ + 'label': beestat.series.compressor_cool_2.name, + 'value': beestat.time( + self.data_.metadata.series.compressor_cool_2.durations[point.x.valueOf()] + ), + 'color': beestat.series.compressor_cool_2.color + }); + } + }); + + if ( + groups.mode.length === 0 && + groups.equipment.length === 0 && + groups.data.length === 0 + ) { + groups.mode.push({ + 'label': 'No data', + 'value': '', + 'color': beestat.style.color.gray.base + }); + } + + sections.push(groups.mode); + sections.push(groups.equipment); + sections.push(groups.data); + + var title = this.x.format('ddd, MMM D @ h:mma'); + + return self.tooltip_formatter_helper_( + title, + sections + ); + }; +}; diff --git a/js/component/chart/runtime_thermostat_summary.js b/js/component/chart/runtime_thermostat_summary.js index 567b941..52436d5 100755 --- a/js/component/chart/runtime_thermostat_summary.js +++ b/js/component/chart/runtime_thermostat_summary.js @@ -3,19 +3,19 @@ * * @param {object} data The chart data. */ -beestat.component.chart2.runtime_thermostat_summary = function(data) { +beestat.component.chart.runtime_thermostat_summary = function(data) { this.data_ = data; - beestat.component.chart2.apply(this, arguments); + beestat.component.chart.apply(this, arguments); }; -beestat.extend(beestat.component.chart2.runtime_thermostat_summary, beestat.component.chart2); +beestat.extend(beestat.component.chart.runtime_thermostat_summary, beestat.component.chart); /** * Override for get_options_xAxis_labels_formatter_. * * @return {Function} xAxis labels formatter. */ -beestat.component.chart2.runtime_thermostat_summary.prototype.get_options_xAxis_labels_formatter_ = function() { +beestat.component.chart.runtime_thermostat_summary.prototype.get_options_xAxis_labels_formatter_ = function() { var current_month; var current_year; @@ -71,7 +71,7 @@ beestat.component.chart2.runtime_thermostat_summary.prototype.get_options_xAxis_ * * @return {Array} All of the series to display on the chart. */ -beestat.component.chart2.runtime_thermostat_summary.prototype.get_options_series_ = function() { +beestat.component.chart.runtime_thermostat_summary.prototype.get_options_series_ = function() { var self = this; var series = []; @@ -128,9 +128,7 @@ beestat.component.chart2.runtime_thermostat_summary.prototype.get_options_series * * @return {Array} The y-axis options. */ -beestat.component.chart2.runtime_thermostat_summary.prototype.get_options_yAxis_ = function() { - var self = this; - +beestat.component.chart.runtime_thermostat_summary.prototype.get_options_yAxis_ = function() { var y_max_hours; var tick_interval; switch (beestat.setting('runtime_thermostat_summary_group_by')) { @@ -199,7 +197,7 @@ beestat.component.chart2.runtime_thermostat_summary.prototype.get_options_yAxis_ * * @return {Function} The tooltip formatter. */ -beestat.component.chart2.runtime_thermostat_summary.prototype.get_options_tooltip_formatter_ = function() { +beestat.component.chart.runtime_thermostat_summary.prototype.get_options_tooltip_formatter_ = function() { var self = this; return function() { diff --git a/js/component/chart/temperature_profiles.js b/js/component/chart/temperature_profiles.js index b0dd31e..0259084 100644 --- a/js/component/chart/temperature_profiles.js +++ b/js/component/chart/temperature_profiles.js @@ -3,19 +3,19 @@ * * @param {object} data The chart data. */ -beestat.component.chart2.temperature_profiles = function(data) { +beestat.component.chart.temperature_profiles = function(data) { this.data_ = data; - beestat.component.chart2.apply(this, arguments); + beestat.component.chart.apply(this, arguments); }; -beestat.extend(beestat.component.chart2.temperature_profiles, beestat.component.chart2); +beestat.extend(beestat.component.chart.temperature_profiles, beestat.component.chart); /** * Override for get_options_xAxis_labels_formatter_. * * @return {Function} xAxis labels formatter. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_xAxis_labels_formatter_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_xAxis_labels_formatter_ = function() { return function() { return this.value + beestat.setting('temperature_unit'); }; @@ -26,7 +26,7 @@ beestat.component.chart2.temperature_profiles.prototype.get_options_xAxis_labels * * @return {Array} All of the series to display on the chart. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_series_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_series_ = function() { var series = []; // Trendline data @@ -124,7 +124,7 @@ beestat.component.chart2.temperature_profiles.prototype.get_options_series_ = fu * * @return {Array} The y-axis options. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_yAxis_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_yAxis_ = function() { var absolute_y_max = Math.max( Math.abs(this.data_.metadata.chart.y_min), Math.abs(this.data_.metadata.chart.y_max) @@ -165,7 +165,7 @@ beestat.component.chart2.temperature_profiles.prototype.get_options_yAxis_ = fun * * @return {Function} The tooltip formatter. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_tooltip_formatter_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_tooltip_formatter_ = function() { var self = this; return function() { @@ -210,7 +210,7 @@ beestat.component.chart2.temperature_profiles.prototype.get_options_tooltip_form * * @return {string} The zoom type. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_chart_zoomType_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_chart_zoomType_ = function() { return null; }; @@ -219,7 +219,7 @@ beestat.component.chart2.temperature_profiles.prototype.get_options_chart_zoomTy * * @return {object} The legend options. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_legend_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_legend_ = function() { return { 'enabled': false }; @@ -230,7 +230,7 @@ beestat.component.chart2.temperature_profiles.prototype.get_options_legend_ = fu * * @return {object} The xAxis options. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_xAxis_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_xAxis_ = function() { return { 'lineWidth': 0, 'tickLength': 0, @@ -273,7 +273,7 @@ beestat.component.chart2.temperature_profiles.prototype.get_options_xAxis_ = fun * * @return {number} The height of the chart. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_chart_height_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_chart_height_ = function() { return 300; }; @@ -282,6 +282,6 @@ beestat.component.chart2.temperature_profiles.prototype.get_options_chart_height * * @return {boolean} Whether or not to connect nulls. */ -beestat.component.chart2.temperature_profiles.prototype.get_options_plotOptions_series_connectNulls_ = function() { +beestat.component.chart.temperature_profiles.prototype.get_options_plotOptions_series_connectNulls_ = function() { return true; }; diff --git a/js/component/chart2.js b/js/component/chart2.js deleted file mode 100755 index d29358b..0000000 --- a/js/component/chart2.js +++ /dev/null @@ -1,476 +0,0 @@ -/** - * A chart. Mostly just a wrapper for the Highcharts stuff so the defaults - * don't have to be set every single time. - */ -beestat.component.chart2 = function() { - var self = this; - - this.addEventListener('render', function() { - self.chart_.reflow(); - }); - - beestat.component.apply(this, arguments); -}; -beestat.extend(beestat.component.chart2, beestat.component); - -beestat.component.chart2.prototype.rerender_on_breakpoint_ = false; - -/** - * Decorate. Calls all the option getters and renders the chart. - * - * @param {rocket.Elements} parent - */ -beestat.component.chart2.prototype.decorate_ = function(parent) { - var options = {}; - - options.credits = this.get_options_credits_(); - options.exporting = this.get_options_exporting_(); - options.chart = this.get_options_chart_(); - options.title = this.get_options_title_(); - options.subtitle = this.get_options_subtitle_(); - options.legend = this.get_options_legend_(); - options.plotOptions = this.get_options_plotOptions_(); - options.xAxis = this.get_options_xAxis_(); - options.yAxis = this.get_options_yAxis_(); - options.series = this.get_options_series_(); - options.tooltip = this.get_options_tooltip_(); - - options.chart.renderTo = parent[0]; - - this.chart_ = Highcharts.chart(options); -}; - -/** - * Reset the chart zoom level all the way out. - */ -beestat.component.chart2.prototype.reset_zoom = function() { - this.chart_.zoomOut(); -}; - -/** - * Export the chart to a PNG. - */ -beestat.component.chart2.prototype.export = function() { - this.chart_.exportChartLocal(); -}; - -/** - * Get the legend options. - * - * @return {object} The legend options. - */ -beestat.component.chart2.prototype.get_options_legend_ = function() { - return { - 'itemStyle': { - 'color': '#ecf0f1', - 'font-weight': '500' - }, - 'itemHoverStyle': { - 'color': '#bdc3c7' - }, - 'itemHiddenStyle': { - 'color': '#7f8c8d' - }, - 'labelFormatter': this.get_options_legend_labelFormatter_() - }; -}; - -/** - * Get the legend labelFormatter options. - * - * @return {Function} The legend labelFormatter options. - */ -beestat.component.chart2.prototype.get_options_legend_labelFormatter_ = function() { - return function() { - return beestat.series[this.name].name; - }; -}; - -/** - * Get the plotOptions. - * - * @return {object} The plotOptions. - */ -beestat.component.chart2.prototype.get_options_plotOptions_ = function() { - return { - 'series': { - 'animation': false, - 'marker': { - 'enabled': false - }, - 'states': { - 'hover': { - 'enabled': false - }, - 'inactive': { - 'opacity': 1 - } - }, - 'connectNulls': this.get_options_plotOptions_series_connectNulls_() - }, - 'column': { - 'pointPadding': 0, - 'borderWidth': 0, - 'stacking': 'normal', - 'dataLabels': { - 'enabled': false - } - } - }; -}; - -/** - * Get whether or not to connect nulls. - * - * @return {boolean} Whether or not to connect nulls. - */ -beestat.component.chart2.prototype.get_options_plotOptions_series_connectNulls_ = function() { - return false; -}; - -/** - * Get the title options. - * - * @return {object} The title options. - */ -beestat.component.chart2.prototype.get_options_title_ = function() { - return { - 'text': null - }; -}; - -/** - * Get the subtitle options - * - * @return {object} The subtitle options. - */ -beestat.component.chart2.prototype.get_options_subtitle_ = function() { - return { - 'text': null - }; -}; - -/** - * Get the chart options. - * - * @return {object} The chart options. - */ -beestat.component.chart2.prototype.get_options_chart_ = function() { - return { - 'style': { - 'fontFamily': 'Montserrat' - }, - 'spacing': [ - beestat.style.size.gutter, - 0, - 0, - 0 - ], - 'zoomType': this.get_options_chart_zoomType_(), - 'panning': true, - 'panKey': 'ctrl', - 'backgroundColor': beestat.style.color.bluegray.base, - 'resetZoomButton': { - 'theme': { - 'display': 'none' - } - }, - 'height': this.get_options_chart_height_() - }; -}; - -/** - * Get the height of the chart. - * - * @return {number} The height of the chart. - */ -beestat.component.chart2.prototype.get_options_chart_height_ = function() { - return null; -}; - -/** - * Get the zoomType option. Return null for no zoom. - * - * @return {string} The zoom type. - */ -beestat.component.chart2.prototype.get_options_chart_zoomType_ = function() { - return 'x'; -}; - -/** - * Get the export options. - * - * @return {object} The export options. - */ -beestat.component.chart2.prototype.get_options_exporting_ = function() { - return { - 'enabled': false, - 'sourceWidth': 980, - 'scale': 1, - 'filename': this.get_options_exporting_filename_(), - 'chartOptions': { - 'credits': { - 'text': 'beestat.io' - }, - 'title': { - 'align': 'left', - 'text': this.get_options_exporting_chartOptions_title_text_(), - 'margin': beestat.style.size.gutter, - 'style': { - 'color': '#fff', - 'font-weight': beestat.style.font_weight.bold, - 'font-size': beestat.style.font_size.large - } - }, - 'subtitle': { - 'align': 'left', - 'text': this.get_options_exporting_chartOptions_subtitle_text_(), - 'style': { - 'color': '#fff', - 'font-weight': beestat.style.font_weight.light, - 'font-size': beestat.style.font_size.normal - } - }, - 'chart': { - 'style': { - 'fontFamily': 'Montserrat, Helvetica, Sans-Serif' - }, - 'spacing': [ - beestat.style.size.gutter, - beestat.style.size.gutter, - beestat.style.size.gutter, - beestat.style.size.gutter - ] - } - } - }; -}; - -/** - * Get the exported chart title. - * - * @return {string} The exported chart title. - */ -beestat.component.chart2.prototype.get_options_exporting_chartOptions_title_text_ = function() { - return this.data_.metadata.chart.title; -}; - -/** - * Get the exported chart subtitle. - * - * @return {string} The exported chart subtitle. - */ -beestat.component.chart2.prototype.get_options_exporting_chartOptions_subtitle_text_ = function() { - return this.data_.metadata.chart.subtitle; -}; - -/** - * Get the exported chart filename. - * - * @return {string} The exported chart filename. - */ -beestat.component.chart2.prototype.get_options_exporting_filename_ = function() { - var title = this.get_options_exporting_chartOptions_title_text_(); - var subtitle = this.get_options_exporting_chartOptions_subtitle_text_(); - - var filename = []; - if (title !== null) { - filename.push(title); - } - - if (subtitle !== null) { - filename.push('-'); - filename.push(subtitle); - } - - if (filename.length === 0) { - filename.push('beestat'); - } - - return filename.join(' '); -}; - -/** - * Get the credits options. - * - * @return {boolean} The credits options. - */ -beestat.component.chart2.prototype.get_options_credits_ = function() { - return false; -}; - -/** - * Get the xAxis options. - * - * @return {object} The xAxis options. - */ -beestat.component.chart2.prototype.get_options_xAxis_ = function() { - return { - 'categories': this.data_.x, - 'lineColor': beestat.style.color.bluegray.light, - 'tickLength': 0, - 'labels': { - 'style': { - 'color': beestat.style.color.gray.base - }, - 'formatter': this.get_options_xAxis_labels_formatter_() - } - }; -}; - -/** - * Get the xAxis label formatter options. Needs to be overridden. - * - * @return {object} The xAxis label formatter options. - */ -beestat.component.chart2.prototype.get_options_xAxis_labels_formatter_ = function() { - return null; -}; - -/** - * Get the yAxis label formatter options. Needs to be overridden. - * - * @return {object} The yAxis label formatter options. - */ -beestat.component.chart2.prototype.get_options_yAxis_ = function() { - return null; -}; - -/** - * Get the series options. Needs to be overridden. - * - * @return {object} The series options. - */ -beestat.component.chart2.prototype.get_options_series_ = function() { - return null; -}; - -/** - * Get the tooltip options. - * - * @return {object} The tooltip options. - */ -beestat.component.chart2.prototype.get_options_tooltip_ = function() { - return { - 'shared': true, - 'useHTML': true, - 'borderWidth': 0, - 'shadow': false, - 'backgroundColor': null, - 'followPointer': true, - 'crosshairs': { - 'width': 1, - 'zIndex': 100, - 'color': beestat.style.color.gray.light, - 'dashStyle': 'shortDot', - 'snap': false - }, - 'positioner': this.get_options_tooltip_positioner_(), - 'formatter': this.get_options_tooltip_formatter_() - }; -}; - -/** - * Get the tooltip formatter. Needs to be overridden. - * - * @return {Function} The tooltip formatter. - */ -beestat.component.chart2.prototype.get_options_tooltip_formatter_ = function() { - return null; -}; - -/** - * Get the tooltip positioner. Makes sure the tooltip is positioned nicely. - * - * @return {Function} The tooltip positioner. - */ -beestat.component.chart2.prototype.get_options_tooltip_positioner_ = function() { - var self = this; - return function(tooltip_width, tooltip_height, point) { - var plot_width = self.chart_.plotWidth; - - var fits_on_left = (point.plotX - tooltip_width) > 0; - var fits_on_right = (point.plotX + tooltip_width) < plot_width; - - var x; - var y = 60; - if (fits_on_left === true) { - x = point.plotX - tooltip_width + self.chart_.plotLeft; - } else if (fits_on_right === true) { - x = point.plotX + self.chart_.plotLeft; - } else { - x = self.chart_.plotLeft; - } - - return { - 'x': x, - 'y': y - }; - }; -}; - -/** - * Get the HTML needed to render a tooltip. - * - * @param {string} title The tooltip title. - * @param {array} sections Data inside the tooltip. - * - * @return {string} The tooltip HTML. - */ -beestat.component.chart2.prototype.tooltip_formatter_helper_ = function(title, sections) { - var tooltip = $.createElement('div') - .style({ - 'background-color': beestat.style.color.bluegray.dark, - 'padding': beestat.style.size.gutter / 2 - }); - - var title_div = $.createElement('div') - .style({ - 'font-weight': beestat.style.font_weight.bold, - 'font-size': beestat.style.font_size.large, - 'margin-bottom': beestat.style.size.gutter / 4, - 'color': beestat.style.color.gray.light - }) - .innerText(title); - tooltip.appendChild(title_div); - - var table = $.createElement('table') - .setAttribute({ - 'cellpadding': '0', - 'cellspacing': '0' - }); - tooltip.appendChild(table); - - sections.forEach(function(section, i) { - if (section.length > 0) { - section.forEach(function(item) { - var tr = $.createElement('tr').style('color', item.color); - table.appendChild(tr); - - var td_label = $.createElement('td') - .style({ - 'font-weight': beestat.style.font_weight.bold - }) - .innerText(item.label); - tr.appendChild(td_label); - - var td_value = $.createElement('td').innerText(item.value) - .style({ - 'padding-left': beestat.style.size.gutter / 4 - }); - tr.appendChild(td_value); - }); - - if (i < sections.length) { - var spacer_tr = $.createElement('tr'); - table.appendChild(spacer_tr); - - var spacer_td = $.createElement('td') - .style('padding-bottom', beestat.style.size.gutter / 4); - spacer_tr.appendChild(spacer_td); - } - } - }); - - return tooltip[0].outerHTML; -}; diff --git a/js/component/modal/help_recent_activity.js b/js/component/modal/help_runtime_detail.js similarity index 59% rename from js/component/modal/help_recent_activity.js rename to js/component/modal/help_runtime_detail.js index 22b3c7b..95e3d5c 100644 --- a/js/component/modal/help_recent_activity.js +++ b/js/component/modal/help_runtime_detail.js @@ -1,29 +1,29 @@ -/** - * Help for the recent activity card. - */ -beestat.component.modal.help_recent_activity = function() { - beestat.component.modal.apply(this, arguments); -}; -beestat.extend(beestat.component.modal.help_recent_activity, beestat.component.modal); - -beestat.component.modal.help_recent_activity.prototype.decorate_contents_ = function(parent) { - parent.appendChild($.createElement('p').innerHTML('View up to the past 7 days of thermostat activity in 5-minute resolution. This can help you visualize daily runtime trends and identify acute system issues. Compare to the Home IQ System & Follow Me charts.')); - - var table = $.createElement('table'); - table.style('color', beestat.style.color.blue.base); - parent.appendChild(table); - - var tr; - var td; - - tr = $.createElement('tr'); - table.appendChild(tr); - - td = $.createElement('td'); - td.setAttribute('valign', 'top'); - tr.appendChild(td); -}; - -beestat.component.modal.help_recent_activity.prototype.get_title_ = function() { - return 'Recent Activity - Help'; -}; +/** + * Help for the runtime detail card. + */ +beestat.component.modal.help_runtime_detail = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_runtime_detail, beestat.component.modal); + +beestat.component.modal.help_runtime_detail.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('View up to the past 7 days of thermostat activity in 5-minute resolution. This can help you visualize daily runtime trends and identify acute system issues. Compare to the Home IQ System & Follow Me charts.')); + + var table = $.createElement('table'); + table.style('color', beestat.style.color.blue.base); + parent.appendChild(table); + + var tr; + var td; + + tr = $.createElement('tr'); + table.appendChild(tr); + + td = $.createElement('td'); + td.setAttribute('valign', 'top'); + tr.appendChild(td); +}; + +beestat.component.modal.help_runtime_detail.prototype.get_title_ = function() { + return 'Runtime Detail - Help'; +}; diff --git a/js/component/modal/runtime_detail_custom.js b/js/component/modal/runtime_detail_custom.js new file mode 100644 index 0000000..9a677a8 --- /dev/null +++ b/js/component/modal/runtime_detail_custom.js @@ -0,0 +1,373 @@ +/** + * Custom date range for the Runtime Detail chart. + */ +beestat.component.modal.runtime_detail_custom = function() { + beestat.component.modal.apply(this, arguments); + this.state_.runtime_detail_range_type = beestat.setting('runtime_detail_range_type'); + this.state_.runtime_detail_range_dynamic = beestat.setting('runtime_detail_range_dynamic'); + this.state_.runtime_detail_range_static_begin = beestat.setting('runtime_detail_range_static_begin'); + this.state_.runtime_detail_range_static_end = beestat.setting('runtime_detail_range_static_end'); + this.state_.error = { + 'max_range': false, + 'invalid_range_begin': false, + 'invalid_range_end': false, + 'out_of_sync_range': false + }; +}; +beestat.extend(beestat.component.modal.runtime_detail_custom, beestat.component.modal); + +/** + * Decorate. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_detail_custom.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('Choose a custom range to display on the Runtime Detail chart.')); + + this.decorate_range_type_(parent); + + if (this.state_.runtime_detail_range_type === 'dynamic') { + this.decorate_range_dynamic_(parent); + } else { + this.decorate_range_static_(parent); + } + + this.decorate_error_(parent); +}; + +/** + * Decorate the range type selector. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_detail_custom.prototype.decorate_range_type_ = function(parent) { + var self = this; + + var button_group = new beestat.component.button_group(); + + button_group.add_button(new beestat.component.button() + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_background_color( + this.state_.runtime_detail_range_type === 'dynamic' + ? beestat.style.color.lightblue.base + : beestat.style.color.bluegray.base + ) + .set_text('Dynamic') + .addEventListener('click', function() { + self.state_.runtime_detail_range_type = 'dynamic'; + self.rerender(); + })); + + button_group.add_button(new beestat.component.button() + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_background_color( + this.state_.runtime_detail_range_type === 'static' + ? beestat.style.color.lightblue.base + : beestat.style.color.bluegray.base + ) + .set_text('Static') + .addEventListener('click', function() { + self.state_.runtime_detail_range_type = 'static'; + self.rerender(); + })); + + (new beestat.component.title('Range Type')).render(parent); + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + button_group.render(column); +}; + +/** + * Decorate the static range inputs. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_detail_custom.prototype.decorate_range_static_ = function(parent) { + var self = this; + + var runtime_detail_static_range_begin; + var runtime_detail_static_range_end; + + /** + * Check whether or not a value is outside of where data is synced. + */ + var check_out_of_sync_range = function() { + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + var min = moment.max( + moment(thermostat.sync_begin), + moment().subtract(1, 'year') + ); + var max = moment(thermostat.sync_end); + + var begin = moment.min( + moment(runtime_detail_static_range_begin.get_value()), + moment(runtime_detail_static_range_end.get_value()) + ); + + var end = moment.max( + moment(runtime_detail_static_range_begin.get_value() + ' 00:00:00'), + moment(runtime_detail_static_range_end.get_value() + ' 23:59:59') + ); + + if ( + begin.isBefore(min) === true || + end.isAfter(max) === true + ) { + self.state_.error.out_of_sync_range = true; + } else { + self.state_.error.out_of_sync_range = false; + } + }; + + runtime_detail_static_range_begin = new beestat.component.input.text() + .set_style({ + 'width': 110, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('calendar') + .set_value(this.state_.runtime_detail_range_static_begin); + + runtime_detail_static_range_begin.addEventListener('blur', function() { + var m = moment(this.get_value()); + if (m.isValid() === true) { + self.state_.error.invalid_range_begin = false; + + var value = m.format('M/D/YYYY'); + + var diff = Math.abs(m.diff(moment(runtime_detail_static_range_end.get_value()), 'day')) + 1; + if (diff > 30) { + self.state_.error.max_range = true; + } else { + self.state_.error.max_range = false; + } + + check_out_of_sync_range(); + + self.state_.runtime_detail_range_static_begin = value; + self.rerender(); + } else { + self.state_.runtime_detail_range_static_begin = this.get_value(); + self.state_.error.invalid_range_begin = true; + self.rerender(); + } + }); + + runtime_detail_static_range_end = new beestat.component.input.text() + .set_style({ + 'width': 110, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('calendar') + .set_value(this.state_.runtime_detail_range_static_end); + + runtime_detail_static_range_end.addEventListener('blur', function() { + var m = moment(this.get_value()); + if (m.isValid() === true) { + self.state_.error.invalid_range_end = false; + + var value = m.format('M/D/YYYY'); + + var diff = Math.abs(m.diff(moment(runtime_detail_static_range_begin.get_value()), 'day')) + 1; + if (diff > 30) { + self.state_.error.max_range = true; + } else { + self.state_.error.max_range = false; + } + + check_out_of_sync_range(); + + self.state_.runtime_detail_range_static_end = value; + self.rerender(); + } else { + self.state_.runtime_detail_range_static_end = this.get_value(); + self.state_.error.invalid_range_end = true; + self.rerender(); + } + }); + + var span; + + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + + span = $.createElement('span').style('display', 'inline-block'); + runtime_detail_static_range_begin.render(span); + column.appendChild(span); + + span = $.createElement('span') + .style({ + 'display': 'inline-block', + 'margin-left': beestat.style.size.gutter, + 'margin-right': beestat.style.size.gutter + }) + .innerText('to'); + column.appendChild(span); + + span = $.createElement('span').style('display', 'inline-block'); + runtime_detail_static_range_end.render(span); + column.appendChild(span); +}; + +/** + * Decorate the dynamic range input. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_detail_custom.prototype.decorate_range_dynamic_ = function(parent) { + var self = this; + + var runtime_detail_range_dynamic = new beestat.component.input.text() + .set_style({ + 'width': 75, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 2 + }) + .set_icon('pound') + .set_value(beestat.setting('runtime_detail_range_dynamic')); + + runtime_detail_range_dynamic.addEventListener('blur', function() { + var value = parseInt(this.get_value(), 10); + if (isNaN(value) === true || value === 0) { + value = 1; + } else if (value > 30) { + value = 30; + } + this.set_value(value); + self.state_.runtime_detail_range_dynamic = value; + }); + + var span; + + var row = $.createElement('div').addClass('row'); + parent.appendChild(row); + var column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + + span = $.createElement('span').style('display', 'inline-block'); + runtime_detail_range_dynamic.render(span); + column.appendChild(span); + + span = $.createElement('span') + .style({ + 'display': 'inline-block', + 'margin-left': beestat.style.size.gutter + }) + .innerText('days'); + column.appendChild(span); +}; + +/** + * Decorate the error area. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.runtime_detail_custom.prototype.decorate_error_ = function(parent) { + var div = $.createElement('div').style('color', beestat.style.color.red.base); + if (this.state_.error.max_range === true) { + div.appendChild($.createElement('div').innerText('Max range is 30 days.')); + } + if (this.state_.error.invalid_range_begin === true) { + div.appendChild($.createElement('div').innerText('Invalid begin date.')); + } + if (this.state_.error.invalid_range_end === true) { + div.appendChild($.createElement('div').innerText('Invalid end date.')); + } + if (this.state_.error.out_of_sync_range === true) { + div.appendChild($.createElement('div').innerText('Detail not available for this range.')); + } + parent.appendChild(div); +}; + +/** + * Get title. + * + * @return {string} Title + */ +beestat.component.modal.runtime_detail_custom.prototype.get_title_ = function() { + return 'Runtime Detail - Custom Range'; +}; + +/** + * Get the buttons that go on the bottom of this modal. + * + * @return {[beestat.component.button]} The buttons. + */ +beestat.component.modal.runtime_detail_custom.prototype.get_buttons_ = function() { + var self = this; + + var cancel = new beestat.component.button() + .set_background_color('#fff') + .set_text_color(beestat.style.color.gray.base) + .set_text_hover_color(beestat.style.color.red.base) + .set_text('Cancel') + .addEventListener('click', function() { + self.dispose(); + }); + + var save; + if ( + this.state_.error.max_range === true || + this.state_.error.invalid_range_begin === true || + this.state_.error.invalid_range_end === true || + this.state_.error.out_of_sync_range === true + ) { + save = new beestat.component.button() + .set_background_color(beestat.style.color.gray.base) + .set_text_color('#fff') + .set_text('Save'); + } else { + save = new beestat.component.button() + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .set_text('Save') + .addEventListener('click', function() { + this + .set_background_color(beestat.style.color.gray.base) + .set_background_hover_color() + .removeEventListener('click'); + + if (moment(self.state_.runtime_detail_range_static_begin).isAfter(moment(self.state_.runtime_detail_range_static_end)) === true) { + var temp = self.state_.runtime_detail_range_static_begin; + self.state_.runtime_detail_range_static_begin = self.state_.runtime_detail_range_static_end; + self.state_.runtime_detail_range_static_end = temp; + } + + beestat.cache.delete('runtime_thermostat'); + beestat.setting( + { + 'runtime_detail_range_type': self.state_.runtime_detail_range_type, + 'runtime_detail_range_dynamic': self.state_.runtime_detail_range_dynamic, + 'runtime_detail_range_static_begin': self.state_.runtime_detail_range_static_begin, + 'runtime_detail_range_static_end': self.state_.runtime_detail_range_static_end + }, + undefined, + function() { + self.dispose(); + } + ); + }); + } + + return [ + cancel, + save + ]; +}; diff --git a/js/js.php b/js/js.php index 98e5371..7a715db 100755 --- a/js/js.php +++ b/js/js.php @@ -50,7 +50,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; - echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; @@ -59,9 +59,9 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; - echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; @@ -81,12 +81,13 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; - echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; diff --git a/js/layer/dashboard.js b/js/layer/dashboard.js index d6a619b..d73920f 100644 --- a/js/layer/dashboard.js +++ b/js/layer/dashboard.js @@ -79,7 +79,9 @@ beestat.layer.dashboard.prototype.decorate_ = function(parent) { cards.push([ { - 'card': new beestat.component.card.recent_activity(), + 'card': new beestat.component.card.runtime_detail( + beestat.setting('thermostat_id') + ), 'size': 12 } ]); diff --git a/js/layer/load.js b/js/layer/load.js index 2a8e337..591bdd4 100644 --- a/js/layer/load.js +++ b/js/layer/load.js @@ -128,6 +128,7 @@ beestat.layer.load.prototype.decorate_ = function(parent) { ); api.set_callback(function(response) { + beestat.cache.set('user', response.user); // Rollbar isn't defined on dev. @@ -152,7 +153,6 @@ beestat.layer.load.prototype.decorate_ = function(parent) { beestat.cache.set('ecobee_sensor', response.ecobee_sensor); beestat.cache.set('address', response.address); beestat.cache.set('announcement', response.announcement); - beestat.cache.set('runtime_thermostat', []); beestat.cache.set('runtime_thermostat_summary', response.runtime_thermostat_summary); // Set the active thermostat_id if this is your first time visiting. @@ -241,6 +241,7 @@ beestat.layer.load.prototype.decorate_ = function(parent) { ) { (new beestat.component.modal.announcements()).render(); } + }); api.send();