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 += '
' + + html += '
' + '
' + '' + escHtml(d.name) + '' + 'lua · ' + protocol + ' · ' + escHtml(driverFile) + '' + @@ -1644,6 +1644,12 @@ return k !== 'client_secret' && k !== 'refresh_token'; }); } + // Cloud credentials already render config.password. A second + // Secrets field bound to the same path (Easee, Zaptec) saves + // whichever input is read last and shows the wrong hint. + if (bodyEl.querySelector('[data-path="drivers.' + dIdx + '.config.password"]')) { + secrets = secrets.filter(function (k) { return k !== 'password'; }); + } if (secrets.length === 0) return; var fs = '
Secrets'; secrets.forEach(function (key) { @@ -1863,11 +1869,7 @@ config.drivers.push(driver); if (S.chargerSetup) S.chargerSetupPending = driver.name; ctx.renderTab("devices"); - if (S.chargerSetup) { - var connection = bodyEl.querySelector('[data-path="drivers.' + (config.drivers.length - 1) + '.config.email"]') || - bodyEl.querySelector('[data-path="drivers.' + (config.drivers.length - 1) + '.config.host"]'); - if (connection) { connection.scrollIntoView({ block: 'center' }); connection.focus(); } - } + revealAddedDevice(config.drivers.length - 1); }; if (chosen.dataset.channel !== "beta") { finishAdd(); @@ -2182,6 +2184,22 @@ // Add/remove-device buttons. var addMqtt = document.getElementById("add-mqtt"); var addModbus = document.getElementById("add-modbus"); + function revealAddedDevice(idx) { + var card = bodyEl.querySelector('.device-item[data-device-idx="' + idx + '"]'); + if (!card) return; + card.style.outline = "2px solid var(--accent, #888)"; + card.style.scrollMargin = "1rem"; + if (card.scrollIntoView) card.scrollIntoView({ block: "center" }); + var field = card.querySelector( + '[data-path="drivers.' + idx + '.config.email"],' + + '[data-path="drivers.' + idx + '.config.host"],' + + '[data-path="drivers.' + idx + '.config.ip"],' + + '[data-path="drivers.' + idx + '.capabilities.mqtt.host"],' + + '[data-path="drivers.' + idx + '.capabilities.modbus.host"]' + ); + if (field && field.focus) field.focus(); + window.setTimeout(function () { card.style.outline = ""; }, 4000); + } if (addMqtt) addMqtt.addEventListener("click", function () { ctx.captureCurrentTab(); config.drivers.push({ @@ -2192,6 +2210,7 @@ mqtt: { host: "", port: 1883, username: "", password: "" }, }); ctx.renderTab("devices"); + revealAddedDevice(config.drivers.length - 1); }); if (addModbus) addModbus.addEventListener("click", function () { ctx.captureCurrentTab(); @@ -2203,6 +2222,7 @@ modbus: { host: "", port: 502, unit_id: 1 }, }); ctx.renderTab("devices"); + revealAddedDevice(config.drivers.length - 1); }); bodyEl.querySelectorAll("[data-remove-idx]").forEach(function (rmBtn) { rmBtn.addEventListener("click", function () { diff --git a/web/update-badge.js b/web/update-badge.js index 15ddd98c..aafae396 100644 --- a/web/update-badge.js +++ b/web/update-badge.js @@ -1178,8 +1178,20 @@ const progress = operationProgress(st, action); const phaseStarted = st.phase_started_at ? Date.parse(st.phase_started_at) : 0; const phaseElapsed = Math.max(0, Math.round((Date.now() - (phaseStarted > 0 ? phaseStarted : this._updateStartedAt)) / 1000)); - const byteProgress = st.progress_unit === "bytes" && st.progress_total > 0 - ? `

${escapeHTML(formatBytes(st.progress_current || 0))} / ${escapeHTML(formatBytes(st.progress_total))}

` + const bytesNow = Number(st.progress_current) || 0; + const bytesTotal = Number(st.progress_total) || 0; + if (st.progress_unit === "bytes" && bytesNow !== this._measuredBytes) { + this._measuredBytes = bytesNow; + this._measuredAt = Date.now(); + } + const byteProgress = st.progress_unit === "bytes" && (bytesNow > 0 || bytesTotal > 0) + ? (bytesTotal > 0 + ? `

${escapeHTML(formatBytes(bytesNow))} / ${escapeHTML(formatBytes(bytesTotal))}

` + : `

${escapeHTML(formatBytes(bytesNow))} written, total unknown

`) + : ""; + const quietFor = this._measuredAt ? Date.now() - this._measuredAt : 0; + const stalled = st.progress_unit === "bytes" && bytesNow > 0 && quietFor > 20000 + ? `

No new measured progress for ${escapeHTML(formatElapsed(Math.round(quietFor / 1000)))}. The clock above is only how long this step has been open.

` : ""; const progressHTML = failed ? "" : `
@@ -1187,7 +1199,8 @@

Step ${progress.step} of ${progress.total} · ${escapeHTML(label)}

This step: ${escapeHTML(formatElapsed(phaseElapsed))}

- ${byteProgress}`; + ${byteProgress} + ${stalled}`; const body = failed ? `

${escapeHTML(st.message || "Update failed")}

diff --git a/web/update-progress.test.mjs b/web/update-progress.test.mjs index 1be66bf8..863a84e9 100644 --- a/web/update-progress.test.mjs +++ b/web/update-progress.test.mjs @@ -13,6 +13,9 @@ test("update UI resumes work and shows each server phase", () => { assert.match(badge, /progress_total/); assert.match(badge, /case "checking":\s+return "Checking service health"/); assert.match(badge, /This step:/); + assert.match(badge, /written, total unknown/); + assert.match(badge, /No new measured progress for/); + assert.doesNotMatch(badge, /Large history databases can take several minutes/); assert.match(badge, /Total:/); assert.match(badge, /Saving rollback point \(settings and config; history stays in place\)/); assert.doesNotMatch(badge, /full history backup/);