From aab92de1cb43579d2ffaf7edc8b17e07310954cf Mon Sep 17 00:00:00 2001 From: kukumagi Date: Wed, 8 Jul 2026 16:25:15 +0300 Subject: [PATCH 1/2] Batch select for plan overrides Make 'Manual Demand' and other overrides easier when needing multiple slots set. You start the batch select by clicking 'Batch select' in dropdown and next slots are selected, once desired slots are selected you can choose eg 'Manual demand' on last slot and it sets it to all selected slots. --- apps/predbat/tests/test_web_if.py | 27 +++++ apps/predbat/web.py | 59 ++++++---- apps/predbat/web_helper.py | 183 ++++++++++++++++++++++++++---- 3 files changed, 221 insertions(+), 48 deletions(-) diff --git a/apps/predbat/tests/test_web_if.py b/apps/predbat/tests/test_web_if.py index 41417e264..4c0539931 100644 --- a/apps/predbat/tests/test_web_if.py +++ b/apps/predbat/tests/test_web_if.py @@ -14,6 +14,23 @@ import shutil import tempfile from components import Components +from web_helper import get_plan_renderer_js + + +def test_plan_renderer_renders_batch_controls(): + """ + Ensure the plan renderer exposes the batch selection controls and syncs + the runtime batch state. + """ + renderer_js = get_plan_renderer_js() + assert "predbatSelectedTimeOverrides" in renderer_js + assert "setSelectedTimeOverrides(getSelectedTimeOverrides())" in renderer_js + assert "openDropdown(dropdownId)" in renderer_js + assert "Batch select" in renderer_js + assert "Cancel Batch" in renderer_js + assert "batch-select-action" in renderer_js + assert "cancel-batch" in renderer_js + assert "querySelectorAll('a.batch-select-action, a.cancel-batch')" in renderer_js def run_test_web_if(my_predbat): @@ -240,6 +257,16 @@ def run_test_web_if(my_predbat): print("ERROR: Unexpected response from /plan_override: {} - {}".format(res.status_code, res.text)) failed = 1 + print("Test POST /plan_override with multiple times") + address = "http://127.0.0.1:5052/plan_override" + data = {"time": "00:00,01:00", "action": "Manual Demand"} + res = requests.post(address, data=data) + if res.status_code in [200]: + accessed_endpoints.add(("POST", "/plan_override")) + else: + print("ERROR: Unexpected response from /plan_override with multiple times: {} - {}".format(res.status_code, res.text)) + failed = 1 + # Test /rate_override POST print("Test POST /rate_override") address = "http://127.0.0.1:5052/rate_override" diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 073d9ac0f..8ea30fb11 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -4144,6 +4144,14 @@ async def html_api_login(self, request): return response + def _parse_override_times(self, time_str): + """ + Split a comma-separated override time string into individual values. + """ + if not time_str: + return [] + return [item.strip() for item in time_str.split(",") if item.strip()] + async def html_rate_override(self, request): """ Handle POST request for rate overrides @@ -4250,33 +4258,38 @@ async def html_plan_override(self, request): if not time_str or not action: return web.json_response({"success": False, "message": "Missing required parameters"}, status=400) - now_utc = self.now_utc - override_time = get_override_time_from_string(now_utc, time_str, self.plan_interval_minutes) - if not override_time: + time_values = self._parse_override_times(time_str) + if not time_values: return web.json_response({"success": False, "message": "Invalid time format"}, status=400) - minutes_from_now = (override_time - now_utc).total_seconds() / 60 - if minutes_from_now >= 48 * 60: - return web.json_response({"success": False, "message": "Override time must be within 48 hours from now."}, status=400) - - selection_option = "{}".format(override_time.strftime("%a %H:%M")) - clear_option = "[{}]".format(override_time.strftime("%a %H:%M")) - if action == "Clear": - await self.base.async_manual_select("manual_demand", selection_option) - await self.base.async_manual_select("manual_demand", clear_option) - else: - if action == "Manual Demand": + now_utc = self.now_utc + for time_value in time_values: + override_time = get_override_time_from_string(now_utc, time_value, self.plan_interval_minutes) + if not override_time: + return web.json_response({"success": False, "message": "Invalid time format"}, status=400) + + minutes_from_now = (override_time - now_utc).total_seconds() / 60 + if minutes_from_now >= 48 * 60: + return web.json_response({"success": False, "message": "Override time must be within 48 hours from now."}, status=400) + + selection_option = "{}".format(override_time.strftime("%a %H:%M")) + clear_option = "[{}]".format(override_time.strftime("%a %H:%M")) + if action == "Clear": await self.base.async_manual_select("manual_demand", selection_option) - elif action == "Manual Charge": - await self.base.async_manual_select("manual_charge", selection_option) - elif action == "Manual Export": - await self.base.async_manual_select("manual_export", selection_option) - elif action == "Manual Freeze Charge": - await self.base.async_manual_select("manual_freeze_charge", selection_option) - elif action == "Manual Freeze Export": - await self.base.async_manual_select("manual_freeze_export", selection_option) + await self.base.async_manual_select("manual_demand", clear_option) else: - return web.json_response({"success": False, "message": "Unknown action"}, status=400) + if action == "Manual Demand": + await self.base.async_manual_select("manual_demand", selection_option) + elif action == "Manual Charge": + await self.base.async_manual_select("manual_charge", selection_option) + elif action == "Manual Export": + await self.base.async_manual_select("manual_export", selection_option) + elif action == "Manual Freeze Charge": + await self.base.async_manual_select("manual_freeze_charge", selection_option) + elif action == "Manual Freeze Export": + await self.base.async_manual_select("manual_freeze_export", selection_option) + else: + return web.json_response({"success": False, "message": "Unknown action"}, status=400) # Refresh plan self.base.update_pending = True diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index b7797a5db..c7a54e586 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -155,6 +155,22 @@ def get_entity_detailed_row_js(): timeCell.innerHTML = timeCell.innerHTML.replace('\u25b6', '\u25bc'); } } + function toggleTimeSelection(time, dropdownId) { + // If batch mode is active, clicking toggles inclusion in the batch. + if (getBatchActive()) { + const selectedTimes = getSelectedTimeOverrides(); + const updated = selectedTimes.includes(time) + ? selectedTimes.filter(item => item !== time) + : selectedTimes.concat(time); + setSelectedTimeOverrides(updated); + openDropdown(dropdownId); + } else { + openDropdown(dropdownId); + } + + // Not in batch mode: just open the dropdown. Do not select the slot yet. + openDropdown(dropdownId); + } """ return text @@ -5723,6 +5739,11 @@ def get_plan_css(): position: relative; background-color: #FFC0CB !important; /* Light pink */ } + .clickable-time-cell.batch-selected { + outline: 2px solid #1976d2; + outline-offset: -2px; + box-shadow: inset 0 0 0 1px rgba(25, 118, 210, 0.35); + } body.dark-mode .override-active { background-color: #93264c !important; /* Dark pink */ } @@ -5960,6 +5981,37 @@ def get_plan_css(): } } + function openDropdown(id) { + closeDropdowns(); + const dropdown = document.getElementById(id); + if (!dropdown) return; + + // Keep the batch action in sync with the runtime batch state and + // ensure only one action is present at a time. + try { + const batchActive = getBatchActive(); + const timeCell = dropdown.closest('td'); + const timeDisplay = timeCell ? timeCell.getAttribute('data-time-display') || '' : ''; + dropdown.querySelectorAll('a.batch-select-action, a.cancel-batch').forEach(n => n.remove()); + + const a = document.createElement('a'); + if (batchActive) { + a.className = 'cancel-batch'; + a.setAttribute('onclick', 'event.stopPropagation(); clearSelectedTimeOverrides()'); + a.textContent = 'Cancel Batch'; + } else { + a.className = 'batch-select-action'; + a.setAttribute('onclick', `event.stopPropagation(); addToBatchSelection('${timeDisplay}', '${id}')`); + a.textContent = 'Batch select'; + } + dropdown.insertBefore(a, dropdown.firstChild || null); + } catch (e) { + // ignore + } + + dropdown.style.display = 'block'; + } + // Function to show error message as a modal popup function showErrorMessage(message) { // Create modal overlay @@ -6175,15 +6227,89 @@ def get_plan_css(): } - // Handle option selection - function handleTimeOverride(time, action) { + function normalizeSelectedTimeOverrides(times) { + if (!Array.isArray(times)) { + times = []; + } + return [...new Set(times.filter(Boolean).map(String))]; + } - // Create a form data object to send the override parameters + // In-memory batch state (does not persist across page reloads) + // Use module-scoped variables so refreshing the page clears the batch. + var _predbatSelectedTimeOverrides = null; // null = not initialised + var _predbatBatchActive = false; + + function getSelectedTimeOverrides() { + // If already initialised in-memory, return that. + if (Array.isArray(_predbatSelectedTimeOverrides)) { + return _predbatSelectedTimeOverrides.slice(); + } + + // First-time initialisation: prefer data attribute from server-rendered HTML + const selectedTimesAttr = document.body.getAttribute('data-selected-times'); + const attrTimes = selectedTimesAttr ? selectedTimesAttr.split(',').filter(Boolean) : []; + _predbatSelectedTimeOverrides = normalizeSelectedTimeOverrides(attrTimes); + return _predbatSelectedTimeOverrides.slice(); + } + + function setSelectedTimeOverrides(times) { + const normalizedTimes = normalizeSelectedTimeOverrides(times); + _predbatSelectedTimeOverrides = normalizedTimes; + document.body.setAttribute('data-selected-times', normalizedTimes.join(',')); + + document.querySelectorAll('td.clickable-time-cell[data-time-display]').forEach(cell => { + const timeValue = cell.getAttribute('data-time-display'); + cell.classList.toggle('batch-selected', normalizedTimes.includes(timeValue)); + }); + } + + // Batch active flag (true when user started batching) + function getBatchActive() { + return !!_predbatBatchActive; + } + + function setBatchActive(val) { + _predbatBatchActive = !!val; + } + + function toggleTimeSelection(time, dropdownId) { + // Only toggle selection when batch mode is active. Otherwise just open + // the dropdown to allow the user to start batching explicitly. + if (getBatchActive()) { + const selectedTimes = getSelectedTimeOverrides(); + const key = time; + const updated = selectedTimes.includes(key) + ? selectedTimes.filter(item => item !== key) + : selectedTimes.concat(key); + setSelectedTimeOverrides(updated); + } + openDropdown(dropdownId); + } + + function clearSelectedTimeOverrides() { + setSelectedTimeOverrides([]); + setBatchActive(false); + } + + function addToBatchSelection(time, dropdownId) { + const selectedTimes = getSelectedTimeOverrides(); + const key = time; + if (!selectedTimes.includes(key)) { + setSelectedTimeOverrides(selectedTimes.concat(key)); + } + setBatchActive(true); + // Start batching and close the dropdown so the user sees the updated + // UI state (other dropdowns will show Cancel Batch when opened). + closeDropdowns(); + } + + function handleTimeOverride(time, action) { + const selectedTimes = getSelectedTimeOverrides(); + const timeValue = selectedTimes.length ? selectedTimes.join(',') : time; const formData = new FormData(); - formData.append('time', time); + formData.append('time', timeValue); formData.append('action', action); - // Send the override request to the server fetch('./plan_override', { method: 'POST', body: formData @@ -6191,9 +6317,9 @@ def get_plan_css(): .then(response => response.json()) .then(data => { if (data.success) { - // Show success message const messageElement = document.createElement('div'); - messageElement.textContent = `${action} override set for ${time}`; + const timesLabel = selectedTimes.length > 1 ? `${selectedTimes.length} slots` : time; + messageElement.textContent = `${action} override set for ${timesLabel}`; messageElement.style.position = 'fixed'; messageElement.style.top = '65px'; messageElement.style.right = '10px'; @@ -6204,14 +6330,13 @@ def get_plan_css(): messageElement.style.zIndex = '1000'; document.body.appendChild(messageElement); - // Auto-remove message after 3 seconds setTimeout(() => { messageElement.style.opacity = '0'; messageElement.style.transition = 'opacity 0.5s'; setTimeout(() => messageElement.remove(), 500); }, 3000); - // Reload the page to show the updated plan + clearSelectedTimeOverrides(); setTimeout(() => location.reload(), 1000); } else { showErrorMessage(data.message || 'Unknown error'); @@ -6222,7 +6347,6 @@ def get_plan_css(): showErrorMessage(error.message); }); - // Close dropdown after selection closeDropdowns(); } @@ -6647,29 +6771,34 @@ def get_plan_renderer_js(): overrideClass = 'override-freeze-export'; } - let html = ``; + let html = ``; html += timeDisplay; html += ''; @@ -6739,9 +6868,9 @@ def get_plan_renderer_js(): html += `'; return html; @@ -6766,9 +6895,9 @@ def get_plan_renderer_js(): html += `'; return html; @@ -7031,6 +7160,7 @@ def get_plan_renderer_js(): // Render table const editable = (currentView === 'plan'); container.innerHTML = renderPlanTable(data, overrides, showDebug, editable); + setSelectedTimeOverrides(getSelectedTimeOverrides()); // Apply dark mode colors if needed updateTableColors(); @@ -7908,9 +8038,12 @@ def get_menu_html(calculating, default_page, arg_errors, THIS_VERSION, battery_s } // Initialise menu on page load -document.addEventListener("DOMContentLoaded", function() { -setActiveMenuItem(); -startStatusUpdates(); + document.addEventListener("DOMContentLoaded", function() { + // Initialise in-memory batch state (ensures a fresh state on each load) + _predbatSelectedTimeOverrides = null; + _predbatBatchActive = false; + setActiveMenuItem(); + startStatusUpdates(); // For each menu item, add click handler to set it as active const menuLinks = document.querySelectorAll('.menu-bar a'); From 0a8ccad0e2910c86f1fcbd8527c2904284d5fc36 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:36:55 +0000 Subject: [PATCH 2/2] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/web_helper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index c7a54e586..5bd0aaf37 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -159,8 +159,8 @@ def get_entity_detailed_row_js(): // If batch mode is active, clicking toggles inclusion in the batch. if (getBatchActive()) { const selectedTimes = getSelectedTimeOverrides(); - const updated = selectedTimes.includes(time) - ? selectedTimes.filter(item => item !== time) + const updated = selectedTimes.includes(time) + ? selectedTimes.filter(item => item !== time) : selectedTimes.concat(time); setSelectedTimeOverrides(updated); openDropdown(dropdownId);