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..5bd0aaf37 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');