From 7529dc5b78f5c51aa0c021b4ae1643c3bafad334 Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Sat, 7 Feb 2026 21:23:07 -0500 Subject: [PATCH] Added historical temperature profiles and profile download --- js/component/card/temperature_profiles.js | 187 ++++++++++++++++-- .../modal/temperature_profiles_info.js | 34 +++- js/layer/load.js | 8 + 3 files changed, 213 insertions(+), 16 deletions(-) diff --git a/js/component/card/temperature_profiles.js b/js/component/card/temperature_profiles.js index 0bd3e11..9488c51 100644 --- a/js/component/card/temperature_profiles.js +++ b/js/component/card/temperature_profiles.js @@ -19,7 +19,7 @@ beestat.component.card.temperature_profiles = function(thermostat_id) { }, 10); beestat.dispatcher.addEventListener( - ['cache.thermostat'], + ['cache.thermostat', 'cache.profile'], change_function ); @@ -27,6 +27,21 @@ beestat.component.card.temperature_profiles = function(thermostat_id) { }; beestat.extend(beestat.component.card.temperature_profiles, beestat.component.card); +/** + * Override subtitle to store a reference for live updates. + * + * @param {rocket.Elements} parent + */ +beestat.component.card.temperature_profiles.prototype.decorate_subtitle_ = function(parent) { + this.subtitle_element_ = $.createElement('div') + .style({ + 'font-weight': beestat.style.font_weight.light, + 'margin-bottom': (beestat.style.size.gutter / 4) + }); + this.subtitle_element_.innerHTML(this.get_subtitle_() || ''); + parent.appendChild(this.subtitle_element_); +}; + /** * Decorate card. * @@ -63,6 +78,79 @@ beestat.component.card.temperature_profiles.prototype.decorate_contents_ = funct this.chart_ = new beestat.component.chart.temperature_profiles(data); this.chart_.render(chart_container); + + // Historical profile slider + var profiles = this.get_profiles_(); + if (profiles.length >= 2) { + var self = this; + + if (this.profile_index_ === undefined || this.profile_index_ >= profiles.length) { + this.profile_index_ = profiles.length - 1; + } + + var slider_container = $.createElement('div').style({ + 'display': 'flex', + 'align-items': 'center', + 'margin-top': '8px' + }); + parent.appendChild(slider_container); + + var range_container = $.createElement('div').style({ + 'flex-grow': '1' + }); + slider_container.appendChild(range_container); + + var date_label = $.createElement('div').style({ + 'white-space': 'nowrap', + 'margin-left': '8px', + 'min-width': '100px', + 'text-align': 'right' + }); + date_label.innerText( + moment(profiles[this.profile_index_].profile.metadata.generated_at).format('MMM D, YYYY') + ); + slider_container.appendChild(date_label); + + var range = new beestat.component.input.range(); + range + .set_min(0) + .set_max(profiles.length - 1) + .set_value(this.profile_index_) + .render(range_container); + + range.addEventListener('input', function() { + self.profile_index_ = parseInt(range.get_value(), 10); + + date_label.innerText( + moment(profiles[self.profile_index_].profile.metadata.generated_at).format('MMM D, YYYY') + ); + + var new_data = self.get_data_(); + + // Update the chart data in-place to avoid DOM recreation + self.chart_.data_ = new_data; + var new_series = self.chart_.get_options_series_(); + new_series.forEach(function(s) { + if (s.data === undefined) { + s.data = []; + } + }); + self.chart_.chart_.update({ + 'series': new_series, + 'exporting': { + 'filename': self.chart_.get_options_exporting_filename_(), + 'chartOptions': { + 'subtitle': { + 'text': new_data.metadata.chart.subtitle + } + } + } + }, true, false, false); + + // Update the card subtitle + self.subtitle_element_.innerHTML(self.get_subtitle_() || ''); + }); + } }; /** @@ -90,9 +178,16 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { } }; + var selected_profile = this.get_selected_profile_(); + var profiles = this.get_profiles_(); + var is_current = (this.profile_index_ === undefined || this.profile_index_ === profiles.length - 1); + if ( - thermostat.profile === null || - moment().diff(moment(thermostat.profile.metadata.generated_at), 'days') >= 7 + is_current && + ( + selected_profile === null || + moment().diff(moment(selected_profile.metadata.generated_at), 'days') >= 7 + ) ) { this.show_loading_('Fetching'); new beestat.api() @@ -117,15 +212,15 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { beestat.cache.set('thermostat', response.thermostat); }) .send(); - } else { + } else if (selected_profile !== null) { const profile_extremes = this.get_profile_extremes_(5); var y_min = Infinity; var y_max = -Infinity; - for (var type in thermostat.profile.temperature) { + for (var type in selected_profile.temperature) { // Cloned because I mutate this data for temperature conversions. var profile = beestat.clone( - thermostat.profile.temperature[type] + selected_profile.temperature[type] ); if (profile !== null) { @@ -213,6 +308,16 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { } } } + + // Lock the y-axis scale to the most recent profile so historical + // profiles don't cause the chart to jump around. + if (is_current) { + this.current_y_min_ = y_min; + this.current_y_max_ = y_max; + } else if (this.current_y_min_ !== undefined) { + data.metadata.chart.y_min = this.current_y_min_; + data.metadata.chart.y_max = this.current_y_max_; + } } return data; @@ -233,21 +338,21 @@ beestat.component.card.temperature_profiles.prototype.get_title_ = function() { * @return {string} The subtitle. */ beestat.component.card.temperature_profiles.prototype.get_subtitle_ = function() { - const thermostat = beestat.cache.thermostat[this.thermostat_id_]; + var selected_profile = this.get_selected_profile_(); // If the profile has not yet been generated. - if (thermostat.profile === null) { + if (selected_profile === null) { return null; } const generated_at_m = moment( - thermostat.profile.metadata.generated_at + selected_profile.metadata.generated_at ); let duration_text = ''; // How much data was used to generate this. - const duration_weeks = Math.round(thermostat.profile.metadata.duration / 7); + const duration_weeks = Math.round(selected_profile.metadata.duration / 7); duration_text += ' from the past'; if (duration_weeks === 0) { duration_text += ' few days'; @@ -260,7 +365,7 @@ beestat.component.card.temperature_profiles.prototype.get_subtitle_ = function() } duration_text += ' of data'; - return 'Generated ' + generated_at_m.format('MMM Do @ h a') + duration_text + ' (updated weekly).'; + return 'Generated ' + generated_at_m.format('MMM Do, YYYY @ h a') + duration_text + ' (updated weekly).'; }; /** @@ -281,11 +386,29 @@ beestat.component.card.temperature_profiles.prototype.decorate_top_right_ = func })); if (this.has_data_() === true) { + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Download Profile') + .set_icon('code_tags') + .set_callback(function() { + var profile = self.get_selected_profile_(); + var blob = new Blob( + [JSON.stringify(profile, null, 2)], + {'type': 'application/json'} + ); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'Temperature Profile - ' + + moment(profile.metadata.generated_at).format('YYYY-MM-DD') + + '.json'; + a.click(); + URL.revokeObjectURL(a.href); + })); + menu.add_menu_item(new beestat.component.menu_item() .set_text('More Info') .set_icon('information') .set_callback(function() { - new beestat.component.modal.temperature_profiles_info().render(); + new beestat.component.modal.temperature_profiles_info(self.get_selected_profile_()).render(); })); } @@ -384,6 +507,46 @@ beestat.component.card.temperature_profiles.prototype.get_profile_extremes_ = fu return extremes; }; +/** + * Get all historical profiles for this thermostat, sorted by generated_at ascending. + * + * @return {Array} + */ +beestat.component.card.temperature_profiles.prototype.get_profiles_ = function() { + if (beestat.cache.profile === undefined) { + return []; + } + + var self = this; + return Object.values(beestat.cache.profile) + .filter(function(p) { + return p.thermostat_id === self.thermostat_id_; + }) + .sort(function(a, b) { + return a.profile.metadata.generated_at > b.profile.metadata.generated_at ? 1 : -1; + }); +}; + +/** + * Get the currently selected profile object (the one to render on the chart). + * Falls back to the current thermostat profile when no historical selection. + * + * @return {object|null} + */ +beestat.component.card.temperature_profiles.prototype.get_selected_profile_ = function() { + var profiles = this.get_profiles_(); + + if ( + this.profile_index_ !== undefined && + profiles.length > 0 && + profiles[this.profile_index_] !== undefined + ) { + return profiles[this.profile_index_].profile; + } + + return beestat.cache.thermostat[this.thermostat_id_].profile; +}; + /** * Get whether or not there is any data to be displayed. * diff --git a/js/component/modal/temperature_profiles_info.js b/js/component/modal/temperature_profiles_info.js index 892a010..162cdb4 100644 --- a/js/component/modal/temperature_profiles_info.js +++ b/js/component/modal/temperature_profiles_info.js @@ -1,13 +1,38 @@ /** * Temperature Profiles Details + * + * @param {object} profile The profile object to display info for. */ -beestat.component.modal.temperature_profiles_info = function() { +beestat.component.modal.temperature_profiles_info = function(profile) { + this.profile_ = profile; beestat.component.modal.apply(this, arguments); }; beestat.extend(beestat.component.modal.temperature_profiles_info, beestat.component.modal); beestat.component.modal.temperature_profiles_info.prototype.decorate_contents_ = function(parent) { - const thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + var date_container = $.createElement('div') + .style({ + 'margin-bottom': '16px', + 'font-weight': beestat.style.font_weight.light + }); + var generated_at_m = moment(this.profile_.metadata.generated_at); + var duration_weeks = Math.round(this.profile_.metadata.duration / 7); + var duration_text = ' from the past'; + if (duration_weeks === 0) { + duration_text += ' few days'; + } else if (duration_weeks === 1) { + duration_text += ' week'; + } else if (duration_weeks >= 52) { + duration_text += ' year'; + } else { + duration_text += ' ' + duration_weeks + ' weeks'; + } + duration_text += ' of data'; + + date_container.innerText( + 'Generated ' + generated_at_m.format('MMM Do, YYYY @ h a') + duration_text + ' (updated weekly).' + ); + parent.appendChild(date_container); const container = $.createElement('div') .style({ @@ -18,6 +43,7 @@ beestat.component.modal.temperature_profiles_info.prototype.decorate_contents_ = parent.appendChild(container); const fields = []; + var self = this; [ 'heat_1', @@ -28,8 +54,8 @@ beestat.component.modal.temperature_profiles_info.prototype.decorate_contents_ = 'cool_2', 'resist' ].forEach(function(type) { - if (thermostat.profile.temperature[type] !== null) { - const profile = thermostat.profile.temperature[type]; + if (self.profile_.temperature[type] !== null) { + const profile = self.profile_.temperature[type]; // Convert the data to Celsius if necessary const deltas_converted = {}; diff --git a/js/layer/load.js b/js/layer/load.js index 5e76b77..a310c2b 100644 --- a/js/layer/load.js +++ b/js/layer/load.js @@ -141,6 +141,13 @@ beestat.layer.load.prototype.decorate_ = function(parent) { 'session' ); + api.add_call( + 'profile', + 'read_id', + {}, + 'profile' + ); + api.set_callback(function(response) { beestat.cache.set('user', response.user); @@ -161,6 +168,7 @@ beestat.layer.load.prototype.decorate_ = function(parent) { beestat.cache.set('runtime_thermostat_summary', response.runtime_thermostat_summary); beestat.cache.set('stripe_event', response.stripe_event); beestat.cache.set('session', response.session); + beestat.cache.set('profile', response.profile); // Send you to the no thermostats layer if none were returned. if(Object.keys(response.thermostat).length === 0) {