diff --git a/.changeset/ui-reported-issues.md b/.changeset/ui-reported-issues.md new file mode 100644 index 00000000..d6d18833 --- /dev/null +++ b/.changeset/ui-reported-issues.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Settings no longer shows a second Easee password, and a newly added device is scrolled into view. Charging without a schedule no longer says the ready time has passed. A site with no devices does not show a legacy plan. The update dialog shows measured bytes, including when the total is unknown, and says when that measurement has stopped. diff --git a/web/app.js b/web/app.js index 0697d683..a81c956d 100644 --- a/web/app.js +++ b/web/app.js @@ -2883,6 +2883,9 @@ text = "Waiting for tomorrow's electricity prices — until they arrive (~13:00) the car charges from PV surplus only."; } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "pv_surplus_pause") { text = "Paused: waiting for PV surplus — solar is below the charger's minimum step right now." + kwPlanned; + } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "no_plan_budget" && !hasSchedule) { + text = "No schedule set. Create a schedule or charge manually."; + tone = "var(--text)"; } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "no_plan_budget") { var deadlineMs = lp.target_time ? Date.parse(lp.target_time) : NaN; if (isFinite(deadlineMs) && deadlineMs <= Date.now()) { diff --git a/web/ev-commanded-reason.test.mjs b/web/ev-commanded-reason.test.mjs index 22dd356f..017440d8 100644 --- a/web/ev-commanded-reason.test.mjs +++ b/web/ev-commanded-reason.test.mjs @@ -14,6 +14,8 @@ test('every pause reason has its own sentence', () => { assert.match(source, /Paused for safety: site-meter data is stale/); assert.match(source, /commanded_reason === "pv_surplus_pause"/); assert.match(source, /Paused: waiting for PV surplus/); + assert.match(source, /commanded_reason === "no_plan_budget" && !hasSchedule/); + assert.match(source, /No schedule set\. Create a schedule or charge manually\./); assert.match(source, /commanded_reason === "no_plan_budget"/); assert.match(source, /The ready time has passed, so FTW is not charging/); assert.match(source, /Battery level is assumed, not read from the car/); diff --git a/web/plan-empty.test.mjs b/web/plan-empty.test.mjs new file mode 100644 index 00000000..211be7e6 --- /dev/null +++ b/web/plan-empty.test.mjs @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("./plan.js", import.meta.url), "utf8"); + +test("a site with no devices does not show a legacy plan", () => { + assert.match(source, /function configuredDeviceCount\(status\)/); + assert.match(source, /if \(configuredDeviceCount\(state\.status\) === 0\)/); + assert.match(source, /No devices yet — add a device in Settings, and the plan starts once FTW can see your site\./); +}); diff --git a/web/plan.js b/web/plan.js index e339d267..f23dcaa2 100644 --- a/web/plan.js +++ b/web/plan.js @@ -321,6 +321,14 @@ import { : 'Mathematical optimizer unavailable. This plan uses the built-in Go fallback.' + reason; } + function configuredDeviceCount(status) { + const drivers = status && status.drivers; + if (!drivers) return 0; + if (Array.isArray(drivers)) return drivers.length; + if (typeof drivers === 'object') return Object.keys(drivers).length; + return 0; + } + function render() { const canvas = document.getElementById('plan-chart'); if (!canvas) return; @@ -345,6 +353,14 @@ import { const { tMin, tMax } = horizonBounds(state.horizon); const xScale = t => pad.l + (t - tMin) / (tMax - tMin) * plotW; const plan = state.plan; + if (configuredDeviceCount(state.status) === 0) { + ctx.fillStyle = C.dim; + ctx.font = '14px sans-serif'; + ctx.fillText('No devices yet. Add one in Settings.', pad.l, pad.t + 28); + const summary = document.getElementById('plan-summary'); + if (summary) summary.textContent = 'No devices yet — add a device in Settings, and the plan starts once FTW can see your site.'; + return; + } // Layout: price bars (top) | mode band (thin strip) | power bars (middle) | SoC (bottom) const modeBandH = 10; diff --git a/web/settings/devices-add.test.mjs b/web/settings/devices-add.test.mjs new file mode 100644 index 00000000..4b486284 --- /dev/null +++ b/web/settings/devices-add.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("./tabs/devices.js", import.meta.url), "utf8"); + +test("a cloud password is not rendered again in Secrets", () => { + assert.match(source, /querySelector\('\[data-path="drivers\.' \+ dIdx \+ '\.config\.password"\]'\)/); + assert.match(source, /secrets = secrets\.filter\(function \(k\) \{ return k !== 'password'; \}\)/); +}); + +test("every add path scrolls the new device into view and focuses a connection field", () => { + assert.match(source, /data-device-idx="' \+ idx \+ '"/); + assert.match(source, /function revealAddedDevice\(idx\)/); + assert.match(source, /card\.scrollIntoView\(\{ block: "center" \}\)/); + const calls = source.match(/revealAddedDevice\(/g) || []; + assert.ok(calls.length >= 4, "catalog, mqtt and modbus adds must reveal the new card"); +}); diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index 162dc14a..1ca54ac4 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -1005,7 +1005,7 @@ var entryForDriver = catalogEntryForLua(d.lua); var supportsBattery = catalogHasCapability(d.lua, "battery") && !(entryForDriver && entryForDriver.read_only); - html += '