diff --git a/api/profile.php b/api/profile.php
index 42a1fe0..81a0ca6 100644
--- a/api/profile.php
+++ b/api/profile.php
@@ -255,6 +255,9 @@ class profile extends cora\crud {
$degree_days_base_temperature = 65;
$degree_days = [];
$begin_runtime = [];
+ $extreme_times = [
+ 'high' => []
+ ];
while($current_timestamp <= $end_timestamp) {
// Get a new chunk of data.
@@ -290,19 +293,81 @@ class profile extends cora\crud {
// consistently represented instead of having to do this logic
// throughout the generator.
$runtime = [];
- $degree_days_date = date('Y-m-d', $current_timestamp);
- $degree_days_temperatures = [];
+
+ // $local_datetime = get_local_datetime(
+ // date('Y-m-d H:i:s', $current_timestamp),
+ // $thermostat['time_zone']
+ // );
+
+ // $process_date = date('Y-m-d', $current_timestamp);
+ $process_date = get_local_datetime(
+ date('Y-m-d H:i:s', $current_timestamp),
+ $thermostat['time_zone'],
+ 'Y-m-d'
+ );
+ $process_date_temperatures = [];
+
+ // $extreme_times_date = date('Y-m-d', $current_timestamp);
+
while($row = $result->fetch_assoc()) {
$timestamp = strtotime($row['timestamp']);
- $date = date('Y-m-d', $timestamp);
+ // $date = date('Y-m-d', $timestamp);
+ $date = get_local_datetime(
+ date('Y-m-d H:i:s', $timestamp),
+ $thermostat['time_zone'],
+ 'Y-m-d'
+ );
- // Degree days
- if($date !== $degree_days_date) {
- $degree_days[] = (array_mean($degree_days_temperatures) / 10) - $degree_days_base_temperature;
- $degree_days_date = $date;
- $degree_days_temperatures = [];
+ if($date !== $process_date) {
+
+ // Degree days
+ $degree_days[] = (array_mean(array_column($process_date_temperatures, 'outdoor_temperature')) / 10) - $degree_days_base_temperature;
+
+ // Max temp
+ $extreme_times_width = 10;
+
+ // Sort the temperatures in descending order and pick the top
+ usort($process_date_temperatures, function($a, $b) {
+ return $b['outdoor_temperature'] <=> $a['outdoor_temperature'];
+ });
+
+ $highest_temperatures = array_slice($process_date_temperatures, 0, $extreme_times_width);
+ // print_r($highest_temperatures);
+
+ // Calculate the average timestamp
+ $average_timestamp = array_sum(array_column($highest_temperatures, 'timestamp')) / count($highest_temperatures);
+
+ // Save the average timestamp to $extreme_times using the day number of the year
+ $extreme_times['high'][date('z', $timestamp)] = date('H:i', $average_timestamp);
+
+ $process_date = $date;
+ $process_date_temperatures = [];
}
- $degree_days_temperatures[] = $row['outdoor_temperature'];
+ $process_date_temperatures[] = [
+ 'outdoor_temperature' => $row['outdoor_temperature'],
+ 'timestamp' => $timestamp,
+ 'tmp_timestamp' => date('c', $timestamp)
+ ];
+
+ // Extreme times
+ // if($date !== $extreme_times_date) {
+ // $degree_days[] = (array_mean($process_date_temperatures) / 10) - $degree_days_base_temperature;
+ // $extreme_times_date = $date;
+ // $process_date_temperatures = [];
+ // }
+ // $extreme_times_temperatures[] = $row['outdoor_temperature'];
+
+ /**
+ * Store an extreme_date just like degree days date (or probably
+ * just use the same value), then put all of the outdoor weather
+ * data into an array. When the date changes, process the data. Note
+ * that I should be storing the past 7 days of data in this array.
+ *
+ * Processing the data should find the top X values and then
+ * calculate the average date of them (not midpoint). Do that for
+ * the past 7 days and average those together, then remove one day
+ * of results from the array.
+ */
if($first_timestamp === null) {
$first_timestamp = $row['timestamp'];
@@ -969,6 +1034,9 @@ class profile extends cora\crud {
'latitude' => $thermostat_database['address_latitude'],
'longitude' => $thermostat_database['address_longitude']
],
+ 'extreme_times' => [
+ 'high' => $extreme_times['high']
+ ],
'metadata' => [
'generated_at' => date('c'),
'duration' => $first_timestamp === null ? null : round((time() - strtotime($first_timestamp)) / 86400),
diff --git a/api/user.php b/api/user.php
index 506171e..0829f40 100644
--- a/api/user.php
+++ b/api/user.php
@@ -16,7 +16,7 @@ class user extends cora\crud {
'sync_patreon_status',
'unlink_patreon_account',
],
- 'public' => []
+ 'public' => ['force_log_in']
];
/**
diff --git a/js/beestat/time_to_detail.js b/js/beestat/time_to_detail.js
new file mode 100644
index 0000000..02856a1
--- /dev/null
+++ b/js/beestat/time_to_detail.js
@@ -0,0 +1,267 @@
+beestat.time_to_detail = {};
+
+/**
+ * Get a bunch of data for the current time_to_detail rows. Includes
+ * basically everything you need to make a cool chart.
+ *
+ * @param {number} thermostat_id The thermostat_id to get data for.
+ *
+ * @return {object} The data.
+ */
+beestat.time_to_detail.get_data = function(thermostat_id) {
+ const thermostat = beestat.cache.thermostat[thermostat_id];
+ const operating_mode = beestat.thermostat.get_operating_mode(
+ thermostat.thermostat_id
+ );
+ const linear_trendline = thermostat.profile.temperature[operating_mode].linear_trendline;
+
+ // Convert "heat_1" etc to "heat"
+ const simplified_operating_mode = operating_mode.replace(/[_\d]|auxiliary/g, '');
+
+ var data = {
+ 'x': [],
+ 'series': {},
+ 'metadata': {
+ 'series': {},
+ 'chart': {
+ 'setpoint_reached_m': null
+ }
+ }
+ };
+
+ // Initialize a bunch of stuff.
+ [
+ 'outdoor_temperature',
+ 'indoor_temperature',
+ 'indoor_cool_1_delta',
+ 'indoor_cool_2_delta',
+ 'indoor_heat_1_delta',
+ 'indoor_heat_2_delta',
+ 'indoor_auxiliary_heat_1_delta',
+ 'indoor_auxiliary_heat_2_delta',
+ 'setpoint_heat',
+ 'setpoint_cool'
+ ].forEach(function(series_code) {
+ data.series[series_code] = [];
+ data.metadata.series[series_code] = {
+ 'active': false,
+ 'data': {}
+ };
+
+ if (beestat.series[series_code] !== undefined) {
+ data.metadata.series[series_code].name = beestat.series[series_code].name;
+ } else {
+ data.metadata.series[series_code].name = null;
+ }
+ });
+
+ // Initialize a bunch of stuff.
+ data.metadata.series.outdoor_temperature.active = true;
+ data.metadata.series.indoor_temperature.active = true;
+ // [
+ // 'outdoor_temperature',
+ // 'indoor_temperature'
+ // ].forEach(function(series_code) {
+ // data.metadata.series[series_code].active = true;
+ // });
+
+ begin_m = moment();
+ end_m = moment().add(12, 'hour');
+
+ // Loop.
+ let current_m = begin_m.clone();
+ let current_indoor_temperature = thermostat.temperature;
+ let current_outdoor_temperature;
+ // let current_indoor_cool_1_delta;
+ let current_setpoint_heat;
+ let current_setpoint_cool;
+ while (
+ current_m.isSameOrAfter(end_m) === false
+ ) {
+ data.x.push(current_m.clone());
+
+ current_outdoor_temperature = beestat.time_to_detail.predict_outdoor_temperature(
+ thermostat_id,
+ // thermostat.weather.temperature,
+ // begin_m,
+ current_m
+ );
+ current_indoor_temperature = beestat.time_to_detail.predict_indoor_temperature(
+ thermostat_id,
+ current_indoor_temperature,
+ current_outdoor_temperature,
+ current_m.clone().subtract(1, 'minute'),
+ current_m
+ );
+ // current_indoor_temperature = prediction.indoor_temperature;
+ // current_degrees_per_hour = prediction.degrees_per_hour;
+
+ data.series.outdoor_temperature.push(current_outdoor_temperature);
+ data.metadata.series.outdoor_temperature.data[current_m.valueOf()] = current_outdoor_temperature;
+
+ data.series.indoor_temperature.push(current_indoor_temperature);
+ data.metadata.series.indoor_temperature.data[current_m.valueOf()] = current_indoor_temperature;
+
+ const setpoint = beestat.time_to_detail.get_setpoint(thermostat_id, current_m);
+ [
+ 'cool',
+ 'heat',
+ ].forEach(function(this_operating_mode) {
+ if(this_operating_mode === simplified_operating_mode) {
+ data.metadata.series[`setpoint_${this_operating_mode}`].active = true;
+ data.series[`setpoint_${this_operating_mode}`].push(setpoint[this_operating_mode]);
+ data.metadata.series[`setpoint_${this_operating_mode}`].data[current_m.valueOf()] = setpoint[this_operating_mode];
+ }
+ });
+
+ [
+ 'cool_1',
+ 'cool_2',
+ 'heat_1',
+ 'heat_2',
+ 'auxiliary_heat_1',
+ 'auxiliary_heat_2'
+ ].forEach(function(operating_mode) {
+ if(
+ operating_mode.includes(simplified_operating_mode) === true &&
+ thermostat.profile.temperature[operating_mode] !== null
+ ) {
+ const linear_trendline = thermostat.profile.temperature[operating_mode].linear_trendline;
+ const degrees_per_hour = (linear_trendline.slope * current_outdoor_temperature) + linear_trendline.intercept;
+
+ data.metadata.series[`indoor_${operating_mode}_delta`].active = true;
+ data.series[`indoor_${operating_mode}_delta`].push(degrees_per_hour);
+ data.metadata.series[`indoor_${operating_mode}_delta`].data[current_m.valueOf()] = degrees_per_hour;
+ }
+ });
+
+ if (
+ current_indoor_temperature <= setpoint[simplified_operating_mode] &&
+ data.metadata.chart.setpoint_reached_m === null
+ ) {
+ data.metadata.chart.setpoint_reached_m = current_m.clone();
+
+ // Redefine the end to go 25% further than we have already.
+ end_m = begin_m.clone().add((current_m.diff(begin_m, 'minutes') * 1.25), 'minutes');
+ }
+
+ current_m.add(1, 'minute');
+ }
+
+ return data;
+};
+
+/**
+ * Predict outdoor temperature using a simple sine wave.
+ *
+ * @param {number} thermostat_id The thermostat_id
+ * @param {moment} current_m Timestamp to predict for
+ *
+ * @return {number} Predicted outdoor temperature
+ */
+beestat.time_to_detail.predict_outdoor_temperature = function(thermostat_id, current_m) {
+ const thermostat = beestat.cache.thermostat[thermostat_id];
+
+ const t = (current_m.hours() * 60) + current_m.minutes();
+
+ // Period and frequency constants; one day (in minutes)
+ const period = 1440;
+ const frequency = (2 * Math.PI) / period;
+
+ // Determine the phase shift based on the warmest time of the day.
+ const desired_t_max = beestat.time_to_detail.get_extreme_high_time(thermostat_id, current_m);
+ const default_t_max = period / 4;
+ const phase_shift = desired_t_max - default_t_max;
+
+ // Determine the amplitude and y_offset based on the predicted high and low
+ // temps.
+ // TODO: the low could actually be wrong if the predicted low isn't actually the low of the entire 24h day
+ const temperature_high = thermostat.weather.temperature_high;
+ const temperature_low = thermostat.weather.temperature_low;
+ const amplitude = (temperature_high - temperature_low) / 2;
+ const y_offset = (temperature_high + temperature_low) / 2;
+
+ return amplitude * Math.sin(frequency * (t - phase_shift)) + y_offset;
+}
+
+beestat.time_to_detail.get_extreme_high_time = function(thermostat_id, current_m) {
+ const count = 30;
+ const thermostat = beestat.cache.thermostat[thermostat_id];
+
+ const day_of_year = current_m.dayOfYear(); // 1-indexed
+ let extreme_high_times = [];
+
+ // Function to get extreme high time for a given day of year
+ const get_extreme_high_time_for_day = function(day_of_year) {
+ return thermostat.profile.extreme_times.high[
+ ((day_of_year - 1) + 365) % 365
+ ];
+ };
+
+ // Check from -14 to +7 days
+ for (let i = (-count); i <= (count / 2); i++) {
+ const target_day_of_year = day_of_year + i;
+ const extreme_time = get_extreme_high_time_for_day(target_day_of_year);
+ if (extreme_time !== undefined) {
+ extreme_high_times.push(extreme_time);
+ }
+ }
+
+ // Take the last 15 values, or fewer if not enough
+ extreme_high_times = extreme_high_times.slice(-count);
+
+
+ // Convert to minutes
+ const extreme_high_minutes = extreme_high_times.map(function(extreme_high_time) {
+ const extreme_high_time_m = moment(extreme_high_time, 'HH:mm');
+ return extreme_high_time_m.hours() * 60 + extreme_high_time_m.minutes();
+ });
+
+ // TODO: Remove outliers here if desired
+
+ const average_extreme_high_minutes = extreme_high_minutes.reduce(
+ function(sum, value) {
+ return (sum + value);
+ },
+ 0
+ ) / extreme_high_times.length;
+
+ return average_extreme_high_minutes;
+};
+
+
+beestat.time_to_detail.predict_indoor_temperature = function(thermostat_id, begin_indoor_temperature, outdoor_temperature, begin_m, current_m) {
+ const thermostat = beestat.cache.thermostat[thermostat_id];
+ // const operating_mode = beestat.thermostat.get_operating_mode(
+ // thermostat.thermostat_id
+ // );
+ const operating_mode = 'cool_1';
+ const linear_trendline = thermostat.profile.temperature[operating_mode].linear_trendline;
+ const degrees_per_hour = (linear_trendline.slope * outdoor_temperature) + linear_trendline.intercept;
+ const degrees_per_minute = degrees_per_hour / 60;
+
+ // const prediction = {
+ // 'indoor_temperature': begin_indoor_temperature + (degrees_per_minute * current_m.diff(begin_m, 'minutes'))
+ // };
+ // prediction[`indoor_${operating_mode}_delta`] = degrees_per_hour;
+
+ return begin_indoor_temperature + (degrees_per_minute * current_m.diff(begin_m, 'minutes'));
+}
+
+beestat.time_to_detail.get_setpoint = function(thermostat_id, current_m) {
+ const thermostat = beestat.cache.thermostat[thermostat_id];
+
+ const climates_by_climate_ref = [];
+ thermostat.program.climates.forEach(function(climate) {
+ climates_by_climate_ref[climate.climateRef] = climate;
+ })
+
+ const ecobee_day = current_m.day();
+ const ecobee_half_hour = Math.floor((current_m.hour() * 60 + current_m.minute()) / 30);
+ const schedule = thermostat.program.schedule[ecobee_day][ecobee_half_hour];
+
+ return {
+ 'heat': climates_by_climate_ref[schedule].heatTemp,
+ 'cool': climates_by_climate_ref[schedule].coolTemp
+ };
+}
diff --git a/js/component/card/system.js b/js/component/card/system.js
index d00faca..8d49ece 100644
--- a/js/component/card/system.js
+++ b/js/component/card/system.js
@@ -346,7 +346,6 @@ beestat.component.card.system.prototype.decorate_time_to_temperature_ = function
const container = $.createElement('div').style({
'background': beestat.style.color.bluegray.dark,
'padding': beestat.style.size.gutter / 2,
- 'text-align': 'center',
'margin-top': beestat.style.size.gutter,
'border-radius': beestat.style.size.border_radius
});
@@ -365,13 +364,13 @@ beestat.component.card.system.prototype.decorate_time_to_temperature_ = function
const outdoor_temperature = thermostat.weather.temperature;
const degrees_per_hour = (linear_trendline.slope * outdoor_temperature) + linear_trendline.intercept;
- header_text += ' (' +
- beestat.temperature({
- 'temperature': degrees_per_hour,
- 'delta': true,
- 'units': true
- }) +
- ' / h)';
+ // header_text += ' (' +
+ // beestat.temperature({
+ // 'temperature': degrees_per_hour,
+ // 'delta': true,
+ // 'units': true
+ // }) +
+ // ' / h)';
if (
(
@@ -417,12 +416,55 @@ beestat.component.card.system.prototype.decorate_time_to_temperature_ = function
}
}
- container.appendChild(
+ const grid = $.createElement('div')
+ .style({
+ 'display': 'grid',
+ 'grid-template-columns': '3fr 1fr', // 75% for left, 25% for right
+ 'grid-gap': `${beestat.style.size.gutter}px`,
+ 'align-items': 'center', // Center content vertically
+ });
+
+ const left = $.createElement('div');
+ // .style({
+ // 'overflow-wrap': 'break-word', // Allow wrapping if the content doesn't fit
+ // 'word-wrap': 'break-word',
+ // 'word-break': 'break-word',
+ // });
+
+ left.appendChild(
$.createElement('div')
.style('font-weight', 'bold')
.innerText(header_text)
);
- container.appendChild($.createElement('div').innerText(text));
+ left.appendChild(
+ $.createElement('div')
+ .innerText(text)
+ );
+
+ const right = $.createElement('div')
+ .style({
+ 'text-align': 'right', // Right-align the content of the right column
+ });
+
+ var cancel = new beestat.component.tile()
+ .set_icon('chart_line')
+ .set_shadow(false)
+ .set_background_hover_color('#fff')
+ .set_text_hover_color(beestat.style.color.bluegray.dark)
+ .set_text('Detail')
+ .addEventListener('click', function () {
+ (new beestat.component.modal.time_to_detail()).render();
+ })
+ .render(right);
+
+ grid.appendChild(left);
+ grid.appendChild(right);
+
+ container.appendChild(grid);
+
+ // setTimeout(function() {
+ // (new beestat.component.modal.time_to_detail()).render();
+ // }, 0);
};
/**
diff --git a/js/component/card/three_d.js b/js/component/card/three_d.js
index 8db06d7..fafb6b9 100644
--- a/js/component/card/three_d.js
+++ b/js/component/card/three_d.js
@@ -511,6 +511,7 @@ beestat.component.card.three_d.prototype.decorate_controls_ = function(parent) {
address.normalized.metadata.latitude,
address.normalized.metadata.longitude
);
+ console.info(times);
const sunrise_m = moment(times.sunrise);
const sunrise_percentage = ((sunrise_m.hours() * 60) + sunrise_m.minutes()) / 1440 * 100;
diff --git a/js/component/chart.js b/js/component/chart.js
index 237e9e0..1328fbc 100644
--- a/js/component/chart.js
+++ b/js/component/chart.js
@@ -211,6 +211,7 @@ beestat.component.chart.prototype.get_options_chart_ = function() {
},
'spacing': this.get_options_chart_spacing_(),
// For consistent left spacing on charts with no y-axis values
+ 'marginTop': this.get_options_chart_marginTop_(),
'marginLeft': this.get_options_chart_marginLeft_(),
'marginRight': this.get_options_chart_marginRight_(),
'marginBottom': this.get_options_chart_marginBottom_(),
@@ -230,6 +231,15 @@ beestat.component.chart.prototype.get_options_chart_ = function() {
};
};
+/**
+ * Get the top margin for the chart.
+ *
+ * @return {number} The top margin for the chart.
+ */
+beestat.component.chart.prototype.get_options_chart_marginTop_ = function() {
+ return undefined;
+};
+
/**
* Get the left margin for the chart.
*
diff --git a/js/component/chart/time_to_detail.js b/js/component/chart/time_to_detail.js
new file mode 100644
index 0000000..ffb27f6
--- /dev/null
+++ b/js/component/chart/time_to_detail.js
@@ -0,0 +1,313 @@
+/**
+ * Runtime thermostat detail temperature chart.
+ *
+ * @param {object} data The chart data.
+ */
+beestat.component.chart.time_to_detail = function(data) {
+ this.data_ = data;
+
+ beestat.component.chart.apply(this, arguments);
+};
+beestat.extend(beestat.component.chart.time_to_detail, beestat.component.chart);
+
+/**
+ * Override for get_options_xAxis_.
+ *
+ * @return {object} The xAxis options.
+ */
+beestat.component.chart.time_to_detail.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,
+ 'font-size': '12px'
+ },
+ 'formatter': this.get_options_xAxis_labels_formatter_()
+ },
+ 'crosshair': this.get_options_xAxis_crosshair_(),
+ 'plotLines': [
+ {
+ 'color': beestat.series.outdoor_temperature.color,
+ 'dashStyle': 'ShortDash',
+ 'width': 1,
+ 'label': {
+ 'style': {
+ 'color': beestat.series.outdoor_temperature.color
+ },
+ 'useHTML': true,
+ 'text': beestat.time(
+ this.data_.metadata.chart.setpoint_reached_m.diff(moment(), 'second'),
+ ) + ' (' + this.data_.metadata.chart.setpoint_reached_m.format('h:mm a') + ')'
+ },
+ 'value': this.data_.metadata.chart.setpoint_reached_m.diff(moment(), 'minute'),
+ 'zIndex': 2
+ }
+ ]
+ };
+};
+
+/**
+ * Override for get_options_xAxis_labels_formatter_.
+ *
+ * @return {Function} xAxis labels formatter.
+ */
+beestat.component.chart.time_to_detail.prototype.get_options_xAxis_labels_formatter_ = function() {
+ var current_day;
+ var current_time;
+
+ return function() {
+ var day = this.value.format('ddd');
+
+ var time = this.value.clone();
+ var minutes = time.minutes();
+ var rounded_minutes = Math.round(minutes / 5) * 5;
+ time.minutes(rounded_minutes).seconds(0);
+ time = time.format('h:mm');
+
+ var label_parts = [];
+ if (day !== current_day) {
+ label_parts.push(day);
+ }
+ if (time !== current_time) {
+ label_parts.push(time);
+ }
+ current_day = day;
+ current_time = time;
+
+ return label_parts.join(' ');
+ };
+};
+
+/**
+ * Override for get_options_series_.
+ *
+ * @return {Array} All of the series to display on the chart.
+ */
+beestat.component.chart.time_to_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': (series_code === 'indoor_temperature') ? 0 : 1,
+ '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'
+ });
+ }
+ });
+
+ return series;
+};
+
+/**
+ * Override for get_options_yAxis_.
+ *
+ * @return {Array} The y-axis options.
+ */
+beestat.component.chart.time_to_detail.prototype.get_options_yAxis_ = function() {
+ return [
+ // Indoor Temperature
+ {
+ 'gridLineColor': beestat.style.color.bluegray.light,
+ 'gridLineDashStyle': 'longdash',
+ 'allowDecimals': false,
+ 'title': {'text': null},
+ 'labels': {
+ 'style': {
+ 'color': beestat.style.color.gray.base,
+ 'fontSize': '11px'
+ },
+ 'formatter': function() {
+ return this.value + beestat.setting('units.temperature');
+ }
+ }
+ },
+ // Outdoor Temperature
+ {
+ 'gridLineColor': beestat.style.color.bluegray.light,
+ 'opposite': true,
+ 'gridLineDashStyle': 'longdash',
+ 'allowDecimals': false,
+ 'title': {'text': null},
+ 'labels': {
+ 'style': {
+ 'color': beestat.style.color.gray.base,
+ 'fontSize': '11px'
+ },
+ 'formatter': function() {
+ return this.value + beestat.setting('units.temperature');
+ }
+ }
+ }
+ ];
+};
+
+/**
+ * Override for get_options_tooltip_formatter_.
+ *
+ * @return {Function} The tooltip formatter.
+ */
+beestat.component.chart.time_to_detail.prototype.get_options_tooltip_formatter_ = function() {
+ var self = this;
+
+ return function() {
+ var points = [];
+ var x = this.x;
+
+ var sections = [];
+ var groups = {
+ 'data': [],
+ 'delta': []
+ };
+
+ // Add some other stuff.
+ [
+ 'indoor_temperature',
+ 'outdoor_temperature',
+ 'setpoint_heat',
+ 'setpoint_cool',
+ 'indoor_cool_1_delta',
+ 'indoor_cool_2_delta',
+ 'indoor_heat_1_delta',
+ 'indoor_heat_2_delta',
+ 'indoor_auxiliary_heat_1_delta',
+ // 'indoor_auxiliary_heat_2_delta'
+ ].forEach(function(series_code) {
+ if (
+ self.data_.metadata.series[series_code].active === true
+ ) {
+ points.push({
+ 'series_code': series_code,
+ 'value': self.data_.metadata.series[series_code].data[x.valueOf()],
+ 'color': beestat.series[series_code].color
+ });
+ }
+ });
+
+ points.forEach(function(point) {
+ var label;
+ var value;
+ var color;
+ var group;
+
+ if (
+ point.series_code.includes('temperature') === true ||
+ point.series_code.includes('setpoint') === true
+ ) {
+ group = 'data';
+ label = beestat.series[point.series_code].name;
+ color = beestat.series[point.series_code].color;
+ value = point.value;
+
+ value = beestat.temperature({
+ 'temperature': value,
+ 'input_temperature_unit': beestat.setting('units.temperature'),
+ 'units': true
+ });
+ } else if(point.series_code.includes('delta') === true) {
+ group = 'delta';
+ label = beestat.series[point.series_code].name;
+ color = beestat.series[point.series_code].color;
+ value = point.value;
+
+ value = beestat.temperature({
+ 'temperature': point.value,
+ 'units': true,
+ 'input_temperature_unit': beestat.setting('units.temperature'),
+ 'delta': true,
+ 'type': 'string'
+ }) + ' / h';
+
+ if (point.value.toFixed(1) > 0) {
+ value = '+' + value;
+ }
+ }
+ else {
+ return;
+ }
+
+ groups[group].push({
+ 'label': label,
+ 'value': value,
+ 'color': color
+ });
+ });
+
+ sections.push(groups.data);
+ sections.push(groups.delta);
+
+ var title = this.x.format('ddd, MMM D @ h:mma');
+
+ return self.tooltip_formatter_helper_(
+ title,
+ sections
+ );
+ };
+};
+
+/**
+ * Get the height of the chart.
+ *
+ * @return {number} The height of the chart.
+ */
+beestat.component.chart.time_to_detail.prototype.get_options_chart_height_ = function() {
+ return 350;
+};
+
+/**
+ * Get the top margin for the chart.
+ *
+ * @return {number} The top margin for the chart.
+ */
+beestat.component.chart.time_to_detail.prototype.get_options_chart_marginTop_ = function() {
+ return 20;
+};
+
+/**
+ * Get the left margin for the chart.
+ *
+ * @return {number} The left margin for the chart.
+ */
+beestat.component.chart.time_to_detail.prototype.get_options_chart_marginLeft_ = function() {
+ return 45;
+};
+
+/**
+ * Get the right margin for the chart.
+ *
+ * @return {number} The right margin for the chart.
+ */
+beestat.component.chart.time_to_detail.prototype.get_options_chart_marginRight_ = function() {
+ return 45;
+};
diff --git a/js/component/modal/time_to_detail.js b/js/component/modal/time_to_detail.js
new file mode 100644
index 0000000..788fd46
--- /dev/null
+++ b/js/component/modal/time_to_detail.js
@@ -0,0 +1,44 @@
+/**
+ * Current time_to_detail.
+ */
+beestat.component.modal.time_to_detail = function() {
+ var self = this;
+
+ beestat.dispatcher.addEventListener(
+ 'cache.thermostat',
+ function() {
+ self.rerender();
+ }
+ );
+
+ beestat.component.modal.apply(this, arguments);
+};
+beestat.extend(beestat.component.modal.time_to_detail, beestat.component.modal);
+
+/**
+ * Decorate
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.modal.time_to_detail.prototype.decorate_contents_ = function(parent) {
+ new beestat.component.chart.time_to_detail(
+ beestat.time_to_detail.get_data(beestat.setting('thermostat_id'))
+ ).render(parent);
+};
+
+/**
+ * Get the title of the chart.
+ *
+ * @return {string}
+ */
+beestat.component.modal.time_to_detail.prototype.get_title_ = function() {
+ const thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ const operating_mode = beestat.thermostat.get_operating_mode(
+ thermostat.thermostat_id
+ );
+
+ // Convert "heat_1" etc to "heat"
+ const simplified_operating_mode = operating_mode.replace(/[_\d]|auxiliary/g, '');
+
+ return 'Time to ' + simplified_operating_mode;
+};
diff --git a/js/js.php b/js/js.php
index 0312ee5..1ed202b 100755
--- a/js/js.php
+++ b/js/js.php
@@ -56,6 +56,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
echo '' . PHP_EOL;
echo '' . PHP_EOL;
echo '' . PHP_EOL;
+ echo '' . PHP_EOL;
// Layer
echo '' . PHP_EOL;
@@ -112,6 +113,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;
@@ -144,6 +146,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;