diff --git a/.changeset/vehicle-charge-limit-ui.md b/.changeset/vehicle-charge-limit-ui.md new file mode 100644 index 000000000..4b70b663c --- /dev/null +++ b/.changeset/vehicle-charge-limit-ui.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Let users choose the car's charge limit in their charging goal while keeping existing percent targets unchanged. Show pending saves separately from saved state and write failures. + +Show the current battery level and car limit with their sources, add readable charging windows, and include all cars in the household plan and overview. diff --git a/web/app.css b/web/app.css index cda87684e..c3330000c 100644 --- a/web/app.css +++ b/web/app.css @@ -2958,3 +2958,32 @@ body.ftw-app .twin-profile-toggle button:focus-visible { height: var(--fill-pct, 0%); } } + +/* The same car plan appears in the charger view and the household plan. */ +.ev-charge-levels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 18px 0 10px; } +.ev-charge-levels > div { display: flex; flex-direction: column; gap: 5px; } +.ev-charge-levels span, .ev-charge-levels small { font-size: .78rem; color: var(--text-dim); } +.ev-charge-levels strong { font-size: 1.8rem; font-weight: 600; line-height: 1.2; } +.ev-timeline { --ev-plan-color: #22d3ee; margin: 16px 0; padding: 14px; border: 1px solid var(--line); border-radius: 10px; } +.ev-timeline-heading { display: flex; justify-content: space-between; gap: 12px; font-size: .85rem; } +.ev-timeline-heading > span { color: var(--text-dim); white-space: nowrap; } +.ev-plan-track { position: relative; height: 28px; margin-top: 14px; border-radius: 5px; overflow: hidden; background: color-mix(in srgb, var(--line) 65%, transparent); } +.ev-plan-window { position: absolute; top: 0; bottom: 0; background: var(--ev-plan-color); border-radius: 3px; } +.ev-plan-ticks { display: flex; justify-content: space-between; margin: 5px 0 12px; color: var(--text-dim); font: .67rem var(--mono); } +.ev-plan-windows { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; } +.ev-plan-slot { display: flex; flex-direction: column; gap: 4px; border-left: 3px solid var(--ev-plan-color); padding: 4px 8px; } +.ev-plan-slot strong { font-size: .8rem; font-weight: 600; } +.ev-plan-slot span, .ev-timeline-note, .ev-plan-more { color: var(--text-dim); font-size: .76rem; line-height: 1.5; } +.ev-timeline-note { display: block; margin: 10px 0 0; } +.ev-timeline-note[hidden] { display: none; } +.ev-plan-more { margin-top: 10px; } +.ev-plan-more summary { cursor: pointer; } +.ev-plan-more .ev-plan-windows { margin-top: 8px; } +#overview-ev-plans .ev-timeline { margin-bottom: 0; } + +.ev-plan-ticks > span { text-align: center; } +.ev-plan-ticks > span:first-child { text-align: left; } +.ev-plan-ticks > span:last-child { text-align: right; } +.ev-plan-ticks small { display: block; font: inherit; margin-top: 3px; } +[data-theme="light"] .ev-timeline { --ev-plan-color: #0891b2; } +.ev-charge-levels[hidden] { display: none; } diff --git a/web/app.js b/web/app.js index 7d1246a0a..0697d6835 100644 --- a/web/app.js +++ b/web/app.js @@ -3,6 +3,7 @@ (function () { "use strict"; + const evPlanUI = import("/ev-plan.js").catch(function () { return null; }); const POLL_INTERVAL = 2000; // status poll cadence — snappier cards const historyMigrationUI = import("/history-migration.js").catch(function () { return null; }); function updateHistoryMigration(health) { @@ -2812,9 +2813,14 @@ var text = null; var tone = "var(--text-dim)"; var kwPlanned = lp.plan_total_wh > 0 ? " ~" + (lp.plan_total_wh / 1000).toFixed(1) + " kWh planned." : ""; - var winActive = lp.plan_next_start_ms > 0 && lp.plan_next_start_ms <= Date.now() && Date.now() < lp.plan_next_end_ms; + var winActive = !lp.plan_pending && !lp.plan_outdated && lp.plan_next_start_ms > 0 && lp.plan_next_start_ms <= Date.now() && Date.now() < lp.plan_next_end_ms; var charging = (lp.current_power_w || 0) >= 100; - var hasSchedule = lp.schedule && lp.schedule.soc > 0; + // API current_soc is the controller estimate, even when a car reports + // a separate vehicle_soc. Use the same level as the battery card. + var statusSoC = lp.soc_source === "vehicle" + ? (lp.vehicle_driver && !lp.vehicle_stale ? (lp.vehicle_soc == null ? 0 : lp.vehicle_soc) : NaN) + : lp.current_soc; + var hasSchedule = lp.schedule && (lp.schedule.finish_at_vehicle_limit === true || lp.schedule.soc > 0); if (lp.manual_restore_unconfirmed) { text = manualStatusText(lp, d); } else if (lp.charger && !lp.charger.available) { @@ -2839,9 +2845,12 @@ if (lp.commanded_reason === "fuse_limit") { text += " Rate is limited by the main fuse right now."; } - } else if (lp.commanded_known && lp.commanded_w === 0 && !lp.power_unavailable && + } else if (lp.goal_complete === true) { + text = "The car confirmed this charging goal is complete."; + } else if (lp.finish_at_vehicle_limit !== true && !(lp.schedule && lp.schedule.finish_at_vehicle_limit === true) && + lp.commanded_known && lp.commanded_w === 0 && !lp.power_unavailable && typeof lp.target_soc === "number" && lp.target_soc > 0 && lp.target_soc <= 1 && - typeof lp.current_soc === "number" && lp.current_soc >= lp.target_soc && lp.current_soc <= 1 && + typeof statusSoC === "number" && statusSoC >= lp.target_soc && statusSoC <= 1 && (lp.soc_source === "vehicle" || lp.soc_source === "inferred")) { text = "Charge target reached (" + Math.round(lp.target_soc * 100) + "%)."; text += lp.soc_source === "vehicle" @@ -2901,6 +2910,7 @@ } if (lp.plan_pending && !lp.manual_active && text && text.indexOf("Updating the charging plan") < 0) text += " Updating the charging plan…"; if (lp.manual_save_error) text = (text ? text + " " : "") + "This choice is active now, but could not be saved for restart. FTW is retrying."; + else if (lp.manual_save_pending === true) text = (text ? text + " " : "") + "This choice is active now. Saving it for restart…"; if (!text) return null; var p = document.createElement("p"); p.style.color = tone; @@ -3044,8 +3054,11 @@ matched = lps.loadpoints[0]; } } - if (!carConnected && matched && matched.schedule && matched.schedule.soc > 0) { - freshStatus.textContent = "No car connected. This goal is saved and applies when you plug in." + (matched.plan_pending ? " Updating the plan…" : matched.plan_outdated ? " Charging times are unavailable." : ""); + if (!carConnected && matched && matched.schedule && (matched.schedule.finish_at_vehicle_limit === true || matched.schedule.soc > 0)) { + freshStatus.textContent = matched.goal_complete === true + ? "No car connected. The car confirmed this goal is complete. " + (matched.schedule.recurring + ? "The recurring schedule remains saved." : "Set a new goal for the next charge.") + : "No car connected. This goal is saved and applies when you plug in." + (matched.plan_pending ? " Updating the plan…" : matched.plan_outdated ? " Charging times are unavailable." : ""); } evLastLp = matched; if (matched && matched.charger && !matched.charger.available) { @@ -3095,12 +3108,14 @@ } if (matched) { // Rebuild only for a changed charger or a removed goal. - var lpChanged = evControlsEl == null || evControlsLpId !== matched.id; + var supportsVehicleLimit = lps.vehicle_limit_goal_supported === true; + var lpChanged = evControlsEl == null || evControlsLpId !== matched.id || + evControlsEl.vehicleLimitSupported !== supportsVehicleLimit; if (lpChanged || schedNeedsRebuild || manualNeedsRebuild) { if (evControlsEl && evControlsEl.parentNode === evModalBody) { evModalBody.removeChild(evControlsEl); } - evControlsEl = buildEvControls(matched, siteHasPV(status)); + evControlsEl = buildEvControls(matched, siteHasPV(status), supportsVehicleLimit); evControlsLpId = matched.id; schedNeedsRebuild = false; manualNeedsRebuild = false; @@ -3163,6 +3178,7 @@ if (statusTableEl) statusTableEl.hidden = true; if (evLastLp) { var stale = Object.assign({}, evLastLp, { + read_unavailable: true, charger: { known: true, available: false }, manual: Object.assign({}, evLastLp.manual, { state: "unavailable" }), }); @@ -3726,45 +3742,42 @@ // screen moves with the value. No button. Mounted once per loadpoint; // update() runs on every poll and leaves the slider alone while the // operator is dragging it. - var EV_PLAN_HORIZON_MS = 24 * 3600 * 1000; - function buildEvPlanView(lp, d) { var box = document.createElement("div"); box.style.margin = "0 0 0.6rem 0"; var headline = null; //
from renderEvPlanStatus, or null - // 24 h track: windows from lp.plan_windows placed by wall clock. - var track = document.createElement("div"); - track.className = "ev-plan-track"; - track.style.position = "relative"; - track.style.height = "12px"; - track.style.borderRadius = "3px"; - track.style.background = "color-mix(in srgb, var(--line) 60%, transparent)"; - track.style.margin = "0.5rem 0 0.25rem"; - track.style.overflow = "hidden"; - - var ticks = document.createElement("div"); - ticks.style.display = "flex"; - ticks.style.justifyContent = "space-between"; - ticks.style.fontFamily = "var(--mono)"; - ticks.style.fontSize = "0.65rem"; - ticks.style.color = "var(--text-dim)"; - - var caption = document.createElement("small"); - caption.style.display = "block"; - caption.style.color = "var(--text-dim)"; - caption.style.marginTop = "0.3rem"; - + var levels = document.createElement("div"); + levels.className = "ev-charge-levels"; + function levelColumn(label) { + var col = document.createElement("div"); + var name = document.createElement("span"); name.textContent = label; + var value = document.createElement("strong"); + var source = document.createElement("small"); + col.appendChild(name); col.appendChild(value); col.appendChild(source); + levels.appendChild(col); + return { value: value, source: source }; + } + var currentLevel = levelColumn("Battery now"); + var carLimit = levelColumn("Car’s charge limit"); + var levelsNote = document.createElement("p"); + levelsNote.className = "ev-timeline-note"; var planWrap = document.createElement("div"); - planWrap.appendChild(track); - planWrap.appendChild(ticks); - planWrap.appendChild(caption); + var timeline = null; + var planUI = null; + evPlanUI.then(function (ui) { + if (!ui) throw new Error("Charging view unavailable"); + planUI = ui; + timeline = ui.createChargingTimeline(); + planWrap.appendChild(timeline.el); + update(lastLp, lastDriver); + }).catch(function () { planWrap.textContent = "Charging details could not load. Reload to try again."; }); // Car's current charge. var socWrap = document.createElement("div"); socWrap.style.marginTop = "0.75rem"; - var hdr = sliderHeader("Battery now", "—"); + var hdr = sliderHeader("Update battery estimate", "—"); socWrap.appendChild(hdr.row); var slider = fullWidthSlider(50, hdr.value); slider.setAttribute("aria-label", "Car's current charge, percent"); @@ -3776,8 +3789,10 @@ note.style.minHeight = "1em"; socWrap.appendChild(note); - box.appendChild(planWrap); + box.appendChild(levels); + box.appendChild(levelsNote); box.appendChild(socWrap); + box.appendChild(planWrap); var capacityView = buildEvCapacityView(lp); box.appendChild(capacityView.el); @@ -3800,11 +3815,13 @@ var src = (lpNow && lpNow.soc_source) || ""; var retention = lpNow.soc_retention === "session" ? " FTW keeps this level for the same charging session, including after a box restart." + : lpNow.soc_retention === "pending" + ? " Saving this level for the current charging session…" : lpNow.soc_retention === "error" ? " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting." : " This level must be entered again after a box restart."; if (src === "assumed") return "Battery level needs confirmation. The plan currently assumes " + Math.round(lpNow.current_soc * 100) + " %. Drag to match the car." + retention; - if (src === "vehicle") return "Reported by the car. Drag only to correct drift."; + if (src === "vehicle") return "The car reports its current level automatically."; if (src === "completed") return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car."; return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention; } @@ -3827,7 +3844,7 @@ if (!(res.ok && res.body && res.body.ok)) throw new Error((res.body && res.body.error) || "FTW refused the change."); if (revision !== socRevision) return; note.textContent = "Charge level saved: " + v + " %." + - (!(lastLp.schedule && lastLp.schedule.soc > 0) && !lastLp.manual_active && !lastLp.surplus_only + (!(lastLp.schedule && (lastLp.schedule.finish_at_vehicle_limit === true || lastLp.schedule.soc > 0)) && !lastLp.manual_active && !lastLp.surplus_only ? " Set a ready time, or choose Charge now." : " Reading the updated plan…"); noteTimer = setTimeout(function () { noteTimer = null; if (!socFailed) note.textContent = sourceNote(lastLp); }, 6000); refreshEvModalAfterWrite().then(function () { @@ -3857,73 +3874,41 @@ var lastLp = lp; - function drawTrack(lpNow) { - track.textContent = ""; - ticks.textContent = ""; - var now = Date.now(); - var windows = (lpNow && Array.isArray(lpNow.plan_windows)) ? lpNow.plan_windows : []; - planWrap.hidden = windows.length === 0 || !!lpNow.manual_active; - var shown = 0; - var shownWh = 0; - windows.forEach(function (w) { - var start = Math.max(w.start_ms, now); - var end = Math.min(w.end_ms, now + EV_PLAN_HORIZON_MS); - if (!(end > start)) return; - var seg = document.createElement("div"); - seg.className = "ev-plan-window"; - seg.style.position = "absolute"; - seg.style.top = "0"; - seg.style.bottom = "0"; - seg.style.left = ((start - now) / EV_PLAN_HORIZON_MS * 100) + "%"; - seg.style.width = Math.max(0.8, (end - start) / EV_PLAN_HORIZON_MS * 100) + "%"; - seg.style.background = "var(--accent-e)"; - seg.style.borderRadius = "2px"; - seg.title = evFmtClock(w.start_ms) + "–" + evFmtClock(w.end_ms) + " · " + (w.wh / 1000).toFixed(1) + " kWh"; - track.appendChild(seg); - shown++; - shownWh += w.wh; - }); - for (var h = 0; h <= 24; h += 6) { - var t = document.createElement("span"); - t.textContent = h === 0 ? "now" : evFmtClock(now + h * 3600 * 1000); - ticks.appendChild(t); - } - if (lpNow && lpNow.manual_active) { - caption.textContent = shown > 0 - ? "Manual charge is selected. The plan below resumes when you return to it." - : "Manual charge is selected. Nothing else is planned in the next 24 h."; - } else if (shown > 0) { - var first = windows[0]; - caption.textContent = "Charges " + evFmtClock(first.start_ms) + "–" + evFmtClock(first.end_ms) + - (shown > 1 ? " and " + (shown - 1) + " more window" + (shown > 2 ? "s" : "") : "") + - " · " + (shownWh / 1000).toFixed(1) + " kWh in the next 24 h."; - } else if (lpNow && lpNow.plugged_in) { - caption.textContent = "No charge window in the next 24 h."; - } else { - caption.textContent = "Plug in to see the plan for this car."; - } - } + var lastDriver = d; function update(lpNow, dNow) { lastLp = lpNow; + lastDriver = dNow; capacityView.update(lpNow); var fresh = renderEvPlanStatus(lpNow, dNow); if (headline && headline.parentNode === box) { if (fresh) { box.replaceChild(fresh, headline); } else { box.removeChild(headline); } } else if (fresh) { - box.insertBefore(fresh, planWrap); + box.insertBefore(fresh, levels); } headline = fresh; - drawTrack(lpNow); + if (timeline) timeline.update(lpNow); + var info = planUI && planUI.chargingLevels(lpNow); + if (info) { + currentLevel.value.textContent = info.now; + currentLevel.source.textContent = info.source; + carLimit.value.textContent = info.limit; + carLimit.source.textContent = info.limitSource; + levelsNote.textContent = info.explanation; + } + levels.hidden = !lpNow.plugged_in; + levelsNote.hidden = !lpNow.plugged_in; var plugged = !!(lpNow && lpNow.plugged_in); - socWrap.hidden = !plugged; + socWrap.hidden = !plugged || !!(info && info.fromCar); + slider.disabled = !!lpNow.read_unavailable; if (!plugged) return; var cur = (lpNow.current_soc != null) ? Math.max(0, Math.min(100, Math.round(lpNow.current_soc * 100))) : null; if (!operatorHolds() && cur != null) { slider.value = String(cur); hdr.value.textContent = lpNow.soc_source === "assumed" ? "Not confirmed" : cur + "%"; } - if (!noteTimer && !socFailed && !operatorHolds()) note.textContent = sourceNote(lpNow); + if (lpNow.read_unavailable) note.textContent = "Waiting for current charging data."; + else if (!noteTimer && !socFailed && !operatorHolds()) note.textContent = sourceNote(lpNow); } update(lp, d); @@ -4058,14 +4043,17 @@ // restarts; the backend rolls the deadline forward daily when Recurring // is set and arms the surplus-grab when the home battery is at/above the // threshold (5 pp release hysteresis). - function buildScheduleSection(lp, hasPV) { + function buildScheduleSection(lp, hasPV, supportsVehicleLimit) { var sched = (lp && lp.schedule) || {}; // Convert "minutes-of-day-UTC" to a "HH:MM" string in the browser's // local zone. The UI shows local time everywhere; we marshal back to // UTC minutes on save. - var hasSched = !!(sched.soc || sched.recurring || sched.surplus_unlock_bat_soc); + supportsVehicleLimit = supportsVehicleLimit === true; + var useVehicleLimit = sched.finish_at_vehicle_limit === true; + var hasSched = !!(useVehicleLimit || sched.soc || sched.recurring || sched.surplus_unlock_bat_soc); var initLocalHHMM = hasSched ? utcMinsToLocalHHMM(typeof sched.time_of_day_min_utc === "number" ? sched.time_of_day_min_utc : 360) : "07:00"; var initSoC = typeof sched.soc === "number" && sched.soc > 0 ? sched.soc * 100 : 80; + var percentSoC = useVehicleLimit && typeof sched.soc === "number" ? sched.soc : initSoC / 100; var initRec = !!sched.recurring; var savedUnlock = typeof sched.surplus_unlock_bat_soc === "number" ? sched.surplus_unlock_bat_soc * 100 : 0; // Surplus on/off is derived from the saved threshold: > 0 ⇒ enabled. @@ -4248,16 +4236,57 @@ } paintChips(); + var targetMode = document.createElement("select"); + targetMode.setAttribute("aria-label", "Charge target"); + targetMode.style.cssText = "max-width:100%;padding:0.4rem;background:var(--ink-raised);color:var(--fg);border:1px solid var(--line);border-radius:4px;font:inherit;font-size:0.85rem"; + [["vehicle", "Car's charge limit"], ["percent", "Choose percent"]].forEach(function (item) { + var option = document.createElement("option"); + option.value = item[0]; + option.textContent = item[1]; + targetMode.appendChild(option); + }); + targetMode.value = useVehicleLimit ? "vehicle" : "percent"; + targetMode.disabled = !supportsVehicleLimit; + if (supportsVehicleLimit) box.appendChild(row("Charge to", targetMode)); + + var vehicleLimitHint = document.createElement("p"); + vehicleLimitHint.textContent = "The car decides when to stop. Change its limit in the car or its app."; + vehicleLimitHint.style.cssText = "margin:0.3rem 0 0.7rem;font-size:0.8rem;color:var(--text-dim)"; + box.appendChild(vehicleLimitHint); + var limitDetails = document.createElement("details"); + limitDetails.style.cssText = "margin:0.5rem 0;color:var(--text-dim);font-size:0.8rem"; + var limitSummary = document.createElement("summary"); + limitSummary.textContent = "How this goal works"; + limitSummary.style.cursor = "pointer"; + limitDetails.appendChild(limitSummary); + var limitExplanation = document.createElement("p"); + limitExplanation.textContent = "FTW plans to the car's reported limit when available. Without it, FTW reserves time for up to 100%. An estimated battery level does not end this charge. This choice does not change the limit set in your car."; + limitDetails.appendChild(limitExplanation); + var retentionDetail = document.createElement("p"); + limitDetails.appendChild(retentionDetail); + // Target: same header + full-width slider treatment as the car's // current charge above the goal. var targetHdr = sliderHeader("Charge to", Math.max(0, Math.min(100, Math.round(initSoC))) + "%"); box.appendChild(targetHdr.row); var targetSlider = fullWidthSlider(Math.max(0, Math.min(100, Math.round(initSoC))), targetHdr.value); targetSlider.min = "10"; - targetSlider.step = "5"; + targetSlider.step = "1"; targetSlider.setAttribute("aria-label", "Target charge, percent"); box.appendChild(targetSlider); box.appendChild(row("Ready by", timeInp)); + timeInp.setAttribute("aria-label", "Ready by"); + box.appendChild(limitDetails); + + function applyTargetMode() { + useVehicleLimit = targetMode.value === "vehicle"; + targetHdr.row.style.display = useVehicleLimit ? "none" : "flex"; + targetSlider.hidden = useVehicleLimit; + targetSlider.disabled = useVehicleLimit; + vehicleLimitHint.hidden = !useVehicleLimit; + limitDetails.hidden = !useVehicleLimit; + } + applyTargetMode(); var checkRow = document.createElement("div"); checkRow.style.display = "flex"; @@ -4334,7 +4363,10 @@ b.style.color = "var(--fg)"; return b; } - var chooseBtn = mkBtn("Use " + Math.round(initSoC) + " % by " + initLocalHHMM); + function chooseLabel() { + return "Use " + (useVehicleLimit ? "car's limit" : targetSlider.value + " %") + " by " + timeInp.value; + } + var chooseBtn = mkBtn(chooseLabel()); chooseBtn.style.textTransform = "none"; chooseBtn.style.letterSpacing = "normal"; chooseBtn.addEventListener("click", scheduleSave); @@ -4355,6 +4387,7 @@ status.style.color = "var(--text-dim)"; status.style.marginTop = "0.4rem"; status.style.minHeight = "1em"; + status.setAttribute("role", "status"); status.textContent = hasSched ? "Changes save as you make them; the plan above follows." : "No goal set yet. Choose this goal, or change the level or time."; @@ -4371,6 +4404,10 @@ var writeQueue = Promise.resolve(); var statusTimer = null; function scheduleSave() { + if (useVehicleLimit && !supportsVehicleLimit) { + status.textContent = "This Core version cannot save a car-limit goal."; + return; + } if (saveTimer) clearTimeout(saveTimer); if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; } saveSeq++; @@ -4393,7 +4430,7 @@ } var body = { schedule: { - soc: Number(targetSlider.value) / 100, + soc: percentSoC, time_of_day_min_utc: minUTC, recurring: !!recCb.checked, // All seven days is the wire's zero. @@ -4401,6 +4438,9 @@ surplus_unlock_bat_soc: unlockVal > 0 ? unlockVal / 100 : 0, }, }; + // Older Core versions omit the support flag. Never offer or send an + // unsupported goal that their parser would silently ignore. + if (supportsVehicleLimit) body.schedule.finish_at_vehicle_limit = useVehicleLimit; // Complete writes in order, even when the charger or planner is slow. writeQueue = writeQueue.catch(function () {}).then(function () { if (seq !== saveSeq) return; @@ -4418,14 +4458,23 @@ }).catch(function (e) { if (seq === saveSeq) { chooseBtn.disabled = false; - chooseBtn.textContent = "Use " + targetSlider.value + " % by " + timeInp.value; + chooseBtn.textContent = chooseLabel(); status.textContent = "Schedule not confirmed: " + e.message; } }); }); } - targetSlider.addEventListener("change", scheduleSave); + targetMode.addEventListener("change", function () { + applyTargetMode(); + if (!useVehicleLimit && percentSoC <= 0) percentSoC = Number(targetSlider.value) / 100; + chooseBtn.textContent = chooseLabel(); + scheduleSave(); + }); + targetSlider.addEventListener("change", function () { + percentSoC = Number(targetSlider.value) / 100; + scheduleSave(); + }); timeInp.addEventListener("change", scheduleSave); recCb.addEventListener("change", function () { applyDaysGate(); scheduleSave(); }); dayChips.forEach(function (chip) { @@ -4460,14 +4509,23 @@ box.update = function (nextLp) { surplusBestEffortHint.style.display = nextLp.surplus_only && !nextLp.manual_active ? "" : "none"; + retentionDetail.textContent = nextLp.goal_retention === "pending" + ? "Saving this session’s goal…" + : nextLp.goal_retention === "unavailable" + ? "Your schedule is saved. FTW cannot identify this charging session, so it may not restore this session's goal after a restart." + : nextLp.goal_retention === "session" + ? "This session's goal is saved and can be restored after a restart." + : ""; + retentionDetail.hidden = !retentionDetail.textContent; }; return box; } // One session view. Opening settings never changes the charging mode. - function buildEvControls(lp, hasPV) { + function buildEvControls(lp, hasPV, supportsVehicleLimit) { var container = document.createElement("div"); container.className = "ev-controls"; + container.vehicleLimitSupported = supportsVehicleLimit === true; var manual = buildManualChargeSection(lp); container.appendChild(manual.el); @@ -4481,6 +4539,11 @@ var summary = document.createElement("p"); summary.style.cssText = "margin:0;font-size:0.9rem"; goal.appendChild(summary); + var retentionError = document.createElement("p"); + retentionError.setAttribute("role", "status"); + retentionError.style.cssText = "margin:0.5rem 0;padding-left:0.5rem;border-left:2px solid var(--accent-e);font-size:0.85rem"; + retentionError.textContent = "FTW could not save this charging session. Its goal may not be restored after a restart. Your schedule is still saved."; + goal.appendChild(retentionError); var suspended = document.createElement("small"); suspended.style.cssText = "display:block;color:var(--text-dim);margin-top:0.3rem"; goal.appendChild(suspended); @@ -4489,7 +4552,7 @@ var editLabel = document.createElement("summary"); editLabel.style.cssText = "cursor:pointer;color:var(--accent-e);font-size:0.85rem;margin-top:0.65rem"; editor.appendChild(editLabel); - var schedule = buildScheduleSection(lp, hasPV); + var schedule = buildScheduleSection(lp, hasPV, supportsVehicleLimit); editor.appendChild(schedule); goal.appendChild(editor); var solar = hasPV || lp.surplus_only ? buildPVModeSection(lp) : null; @@ -4499,11 +4562,13 @@ container.update = function (nextLp, d) { manual.el.hidden = !nextLp.plugged_in; manual.update(nextLp, d); + retentionError.hidden = nextLp.goal_retention !== "error"; var s = nextLp.schedule; - var hasGoal = !!(s && s.soc > 0); + var hasGoal = !!(s && (s.finish_at_vehicle_limit === true || s.soc > 0)); summary.textContent = hasGoal - ? Math.round(s.soc * 100) + " % by " + utcMinsToLocalHHMM(s.time_of_day_min_utc) + - (s.recurring ? " · repeats" : " · once") + ? (s.finish_at_vehicle_limit === true ? "Car's charge limit" : Math.round(s.soc * 100) + " %") + + " by " + utcMinsToLocalHHMM(s.time_of_day_min_utc) + + (nextLp.goal_complete === true ? " · completed" : s.recurring ? " · repeats" : " · once") : "No ready time set."; editLabel.textContent = hasGoal ? "Change goal" : "Set a ready time"; suspended.textContent = evIsPaused(nextLp) || nextLp.manual_restore_unconfirmed diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index 685dc5d52..4076398db 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -125,3 +125,17 @@ test('failed hold persistence stays visible until the box reports recovery', () // must remain visible even though there is no active hold anymore. assert.match(render({ ...paused, manual_active: false }).textContent, /could not be saved for restart/); }); + + +test('pending manual save is distinct from an actual write failure', () => { + const strip = source.slice(source.indexOf('function renderEvPlanStatus'), source.indexOf('// Keep controls mounted while polling')); + const render = new Function('document', 'manualStatusText', strip + '; return renderEvPlanStatus;')( + { createElement: () => ({ style: {} }) }, describeManual, + ); + const paused = { ...lp, plugged_in: true, manual_save_pending: true, + manual: { ...lp.manual, state: 'paused', requested_a: 0, requested_w: 0 } }; + assert.match(render(paused).textContent, /Saving it for restart/); + assert.doesNotMatch(render(paused).textContent, /could not be saved/); + assert.doesNotMatch(render({ ...paused, manual_save_pending: false }).textContent, /Saving it/); + assert.match(render({ ...paused, manual_save_error: true }).textContent, /could not be saved/); +}); diff --git a/web/ev-plan-status.test.mjs b/web/ev-plan-status.test.mjs index 5243b1b39..56d7d2776 100644 --- a/web/ev-plan-status.test.mjs +++ b/web/ev-plan-status.test.mjs @@ -59,7 +59,7 @@ test('reached goal explains a stopped charge and names the level source', () => const estimated = planStatus(lp, {}).textContent; assert.match(estimated, /Charge target reached \(80%\).*estimated by FTW/); assert.doesNotMatch(estimated, /No charge window|Choose Charge now/); - assert.match(planStatus({ ...lp, soc_source: 'vehicle' }, {}).textContent, /reported by the car/); + assert.match(planStatus({ ...lp, soc_source: 'vehicle', vehicle_driver: 'car', vehicle_soc: .81 }, {}).textContent, /reported by the car/); for (const change of [ { soc_source: 'assumed' }, { current_soc: .79 }, { target_soc: null }, { power_unavailable: true }, { charger: { known: true, available: false } }, @@ -75,3 +75,41 @@ test('unavailable charger power explains the pause without declaring completion' assert.match(text, /Paused: charger power data is out of date/); assert.doesNotMatch(text, /target reached|Charging on plan/); }); + +test('car-limit goal never declares completion from estimated target_soc', () => { + const lp = { plugged_in: true, charger: { available: true }, current_power_w: 0, + commanded_known: true, commanded_w: 0, target_soc: 1, current_soc: 1, + soc_source: 'inferred', schedule: { soc: 0, finish_at_vehicle_limit: true } }; + assert.doesNotMatch(planStatus(lp, {}).textContent, /target reached|No charging plan yet/); + assert.match(planStatus({ ...lp, charging_declined: true }, {}).textContent, /car stopped asking for charge/); +}); + + +test('only the Core completion flag confirms a car-limit goal, even after restart', () => { + const lp = { plugged_in: true, charger: { available: true }, current_power_w: 0, + commanded_known: true, commanded_w: 0, target_soc: 0, current_soc: .8, + soc_source: 'inferred', finish_at_vehicle_limit: true, + schedule: { soc: .8, finish_at_vehicle_limit: true }, charging_declined: true }; + assert.match(planStatus({ ...lp, goal_complete: true }, {}).textContent, /car confirmed.*goal is complete/); + for (const flag of [false, undefined, 1, 'true']) { + assert.doesNotMatch(planStatus({ ...lp, goal_complete: flag }, {}).textContent, /car confirmed/); + } + assert.doesNotMatch(planStatus({ ...lp, goal_complete: true, manual_active: true }, {}).textContent, /car confirmed/); +}); + + +test('target status uses the reported car level, not the separate controller estimate', () => { + const lp = { plugged_in: true, commanded_known: true, commanded_w: 0, target_soc: .8, + current_soc: .99, vehicle_soc: .45, soc_source: 'vehicle', vehicle_driver: 'car', schedule: { soc: .8 } }; + assert.doesNotMatch(planStatus(lp, {}).textContent, /target reached/); + assert.match(planStatus({ ...lp, current_soc: .4, vehicle_soc: .81 }, {}).textContent, /target reached/); + assert.doesNotMatch(planStatus({ ...lp, vehicle_soc: .81, vehicle_stale: true }, {}).textContent, /target reached/); +}); + +test('live charging does not confirm an outdated planned end time', () => { + const lp = { plugged_in: true, current_power_w: 7000, + plan_next_start_ms: Date.now() - 1000, plan_next_end_ms: Date.now() + 3600000 }; + assert.match(planStatus(lp, {}).textContent, /Charging on plan until/); + assert.doesNotMatch(planStatus({ ...lp, plan_outdated: true }, {}).textContent, /on plan until/); + assert.doesNotMatch(planStatus({ ...lp, plan_pending: true }, {}).textContent, /on plan until/); +}); diff --git a/web/ev-plan.js b/web/ev-plan.js new file mode 100644 index 000000000..1433384b0 --- /dev/null +++ b/web/ev-plan.js @@ -0,0 +1,118 @@ +// Shared charging-plan data and view for the car and household plan. +export function plannedEVWatts(action = {}) { + const powers = action.loadpoint_power_w; + if (powers && Object.keys(powers).length) { + return Object.values(powers).reduce((sum, w) => sum + (Number.isFinite(w) ? Math.max(0, w) : 0), 0); + } + return Number.isFinite(action.loadpoint_w) ? Math.max(0, action.loadpoint_w) : 0; +} + +const fraction = value => Number.isFinite(value) && value >= 0 && value <= 1; +const percent = value => `${Math.round(value * 100)}%`; + +export function chargingLevels(lp = {}) { + if (lp.read_unavailable) return { now: 'Unknown', source: 'Current data unavailable', limit: 'Unknown', + limitSource: 'Current data unavailable', fromCar: false, explanation: 'Waiting for current car and charger data.' }; + const freshCar = !!lp.plugged_in && !!lp.vehicle_driver && !lp.vehicle_stale; + // A reported 0% is omitted by older Core JSON encoders. + const fromCar = freshCar && lp.soc_source === 'vehicle' && fraction(lp.vehicle_soc ?? 0); + const now = fromCar ? (lp.vehicle_soc ?? 0) : lp.current_soc; + const limit = freshCar && fraction(lp.vehicle_charge_limit) && lp.vehicle_charge_limit > 0 + ? lp.vehicle_charge_limit : null; + const unconfirmed = !fraction(now) || lp.soc_source === 'assumed' || lp.soc_source === 'completed'; + const vehicleGoal = lp.schedule?.finish_at_vehicle_limit === true || lp.finish_at_vehicle_limit === true; + const goal = vehicleGoal ? (limit ?? 1) : (lp.schedule?.soc || lp.target_soc); + const target = fraction(goal) && goal > 0 ? Math.min(goal, limit ?? 1) : null; + return { + now: fraction(now) ? percent(now) : 'Unknown', + source: fromCar ? 'Reported by car' : unconfirmed ? 'Needs confirmation' : 'Estimated by FTW', + limit: limit == null ? 'Unknown' : percent(limit), + limitSource: limit == null ? 'Not reported by car' : 'Reported by car', + fromCar, + explanation: lp.manual_active ? 'Your saved goal resumes when you return to the plan.' + : lp.goal_complete === true ? 'The car has confirmed this goal is complete.' + : vehicleGoal && limit == null + ? 'FTW does not know the car’s limit. It reserves charging for up to 100%; the car decides when to stop.' + : target != null ? `Planning from ${fraction(now) ? percent(now) : 'an unknown level'} to ${percent(target)}${unconfirmed ? ' · confirm the current level' : ''}.` : 'Set a goal to plan charging.', + }; +} + +export function chargingPlan(lp, start = Date.now(), end = start + 24 * 3600000) { + let message = ''; + if (!lp) message = 'Charging plan unavailable. Trying again…'; + else if (lp.charger?.available === false || lp.power_unavailable) message = 'Waiting for current charger data.'; + else if (!lp.plugged_in) message = 'Plug in to plan charging.'; + else if (lp.manual_active) message = lp.manual_charge_w === 0 ? 'Charging paused by you.' : 'Charge now is active. Scheduled charging resumes when you return to the plan.'; + else if (lp.goal_complete === true) message = 'Charging goal complete.'; + else if (lp.plan_pending) message = 'Updating charging times…'; + else if (lp.plan_outdated) message = 'Charging times unavailable. Your goal is saved.'; + else if (lp.surplus_only) message = 'PV only · charging follows available solar power.'; + else if (lp.grid_deferred) message = 'Waiting for tomorrow’s electricity prices.'; + const windows = message ? [] : (lp.plan_windows || []).filter(w => + Number.isFinite(w.start_ms) && Number.isFinite(w.end_ms) && w.end_ms > w.start_ms && + Number.isFinite(w.wh) && w.wh > 0 && w.end_ms > start && w.start_ms < end + ).map(w => { + const left = Math.max(start, w.start_ms), right = Math.min(end, w.end_ms); + return { start_ms: left, end_ms: right, wh: w.wh * (right - left) / (w.end_ms - w.start_ms), + partial: left !== w.start_ms || right !== w.end_ms }; + }).sort((a, b) => a.start_ms - b.start_ms); + return { windows, message: message || (windows.length ? '' : 'No charging planned in this period.'), + wh: windows.reduce((sum, w) => sum + w.wh, 0), approximate: windows.some(w => w.partial) }; +} + +function clock(ts) { + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); +} +function dayClock(ts, now) { + const date = new Date(ts), today = new Date(now), tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const day = date.toDateString() === today.toDateString() ? '' + : date.toDateString() === tomorrow.toDateString() ? 'Tomorrow ' : date.toLocaleDateString([], { weekday: 'short' }) + ' '; + return day + clock(ts); +} + +export function createChargingTimeline(doc = document) { + const el = doc.createElement('section'); + el.className = 'ev-timeline'; + el.setAttribute('aria-label', 'Planned car charging'); + function node(tag, className, parent) { + const item = doc.createElement(tag); item.className = className; parent.appendChild(item); return item; + } + const heading = node('div', 'ev-timeline-heading', el); + const title = node('strong', '', heading), energy = node('span', '', heading); + const message = node('p', 'ev-timeline-note', el); + const graphic = node('div', '', el); + const track = node('div', 'ev-plan-track', graphic); + track.setAttribute('aria-hidden', 'true'); + const ticks = node('div', 'ev-plan-ticks', graphic); + const list = node('div', 'ev-plan-windows', el); + const more = node('details', 'ev-plan-more', el); + const summary = node('summary', '', more), rest = node('div', 'ev-plan-windows', more); + const note = node('small', 'ev-timeline-note', el); + note.textContent = 'Planned charging · times can change as FTW replans.'; + return { el, update(lp, { start = Date.now(), end = start + 24 * 3600000, label = 'Next 24 hours' } = {}) { + const data = chargingPlan(lp, start, end); + title.textContent = label; + energy.textContent = data.windows.length ? `${data.approximate ? '≈ ' : ''}${(data.wh / 1000).toFixed(1)} kWh` : ''; + message.textContent = data.message; message.hidden = !data.message; + graphic.hidden = !data.windows.length; list.hidden = !data.windows.length; + note.hidden = !data.windows.length; more.hidden = data.windows.length <= 2; + track.replaceChildren(); ticks.replaceChildren(); list.replaceChildren(); rest.replaceChildren(); + for (let i = 0; i <= 4; i++) { + const tick = node('span', '', ticks), ts = start + (end - start) * i / 4; + const isNow = i === 0 && Math.abs(Date.now() - start) < 60000; + node('span', '', tick).textContent = isNow ? 'Now' : clock(ts); + const day = dayClock(ts, start).replace(clock(ts), '').trim(); + node('small', '', tick).textContent = day || (isNow ? '' : 'Today'); + } + data.windows.forEach((w, i) => { + const bar = node('span', 'ev-plan-window', track); + bar.style.left = `${(w.start_ms - start) / (end - start) * 100}%`; + bar.style.width = `${(w.end_ms - w.start_ms) / (end - start) * 100}%`; + const slot = node('div', 'ev-plan-slot', i < 2 ? list : rest); + node('strong', '', slot).textContent = `${dayClock(w.start_ms, start)}–${dayClock(w.end_ms, w.start_ms)}`; + node('span', '', slot).textContent = `${w.partial ? '≈ ' : ''}${(w.wh / 1000).toFixed(1)} kWh planned`; + }); + summary.textContent = `${Math.max(0, data.windows.length - 2)} more charging windows`; + } }; +} diff --git a/web/ev-plan.test.mjs b/web/ev-plan.test.mjs new file mode 100644 index 000000000..5bed5b4bf --- /dev/null +++ b/web/ev-plan.test.mjs @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chargingLevels, chargingPlan, plannedEVWatts, createChargingTimeline } from './ev-plan.js'; +import { derivePlanBrief } from './plan-brief.js'; +import { readFileSync } from 'node:fs'; + +const hour = 3600000, now = Date.UTC(2026,8,17,20); +const car = { plugged_in: true, current_soc: .45, soc_source: 'inferred', schedule: { finish_at_vehicle_limit: true, soc: .8 } }; +const known = { ...car, vehicle_driver: 'car', vehicle_soc: .47, vehicle_charge_limit: .8, soc_source: 'vehicle' }; + +test('reported battery level is distinct from the controller estimate and target', () => { + const info = chargingLevels(known); + assert.equal(info.now, '47%'); assert.equal(info.limit, '80%'); + assert.equal(info.source, 'Reported by car'); assert.equal(info.fromCar, true); + assert.match(info.explanation, /47% to 80%/); + assert.equal(chargingLevels({ ...known, vehicle_soc: 0 }).now, '0%'); + assert.equal(chargingLevels({ ...known, vehicle_soc: undefined }).now, '0%', 'Core omits zero vehicle_soc'); +}); + +test('stale, missing or unbound car telemetry cannot become a known car limit', () => { + for (const change of [{ vehicle_driver: '' }, { vehicle_stale: true }, { vehicle_charge_limit: 0 }, { vehicle_charge_limit: 1.2 }]) { + const info = chargingLevels({ ...known, ...change }); + assert.equal(info.limit, 'Unknown'); assert.match(info.explanation, /up to 100%/); + } + const info = chargingLevels({ ...car, target_soc: 1 }); + assert.equal(info.limit, 'Unknown'); assert.equal(info.now, '45%'); + assert.equal(info.source, 'Estimated by FTW'); + assert.equal(chargingLevels({ ...car, soc_source: 'assumed' }).source, 'Needs confirmation'); + assert.equal(chargingLevels({ ...car, soc_source: 'completed' }).source, 'Needs confirmation'); + assert.equal(chargingLevels({ ...known, read_unavailable: true }).now, 'Unknown'); +}); + +test('percent goals stay separate from the car limit and respect a lower car cap', () => { + const lp = { ...known, schedule: { soc: .7 } }; + assert.equal(chargingLevels(lp).limit, '80%'); + assert.match(chargingLevels(lp).explanation, /to 70%/); + assert.match(chargingLevels({ ...lp, schedule: { soc: .9 } }).explanation, /to 80%/); +}); + +test('per-car powers win over legacy aggregate without double counting', () => { + assert.equal(plannedEVWatts({ loadpoint_power_w: { a: 3000, b: 5000 }, loadpoint_w: 8000 }), 8000); + assert.equal(plannedEVWatts({ loadpoint_power_w: { a: 0 }, loadpoint_w: 8000 }), 0); + assert.equal(plannedEVWatts({ loadpoint_power_w: {}, loadpoint_w: 3200 }), 3200); + assert.equal(plannedEVWatts({ loadpoint_power_w: { a: NaN, b: -20, c: 100 } }), 100); +}); + +test('household brief finds a future multi-car slot and names its total power', () => { + const brief = derivePlanBrief({ enabled: true, now, status: { mode: 'planner_active' }, plan: { actions: [ + { slot_start_ms: now, slot_len_min: 15, battery_w: 0 }, + { slot_start_ms: now + hour, slot_len_min: 15, loadpoint_power_w: { a: 3000, b: 4000 } }, + ] } }); + assert.equal(brief.next.action, 'Charge EV at 7.0 kW'); + assert.match(brief.next.time, /^At /); +}); + +const windows = [ + { start_ms: now + 8 * hour, end_ms: now + 10 * hour, wh: 14000 }, + { start_ms: now - hour, end_ms: now + hour, wh: 14000 }, + { start_ms: now - 3 * hour, end_ms: now - 2 * hour, wh: 7000 }, + { start_ms: now + 23 * hour, end_ms: now + 25 * hour, wh: 14000 }, +]; +test('24-hour windows are sorted and clipped; partial energy is marked approximate', () => { + const plan = chargingPlan({ ...car, plan_windows: windows }, now); + assert.equal(plan.windows.length, 3); + assert.equal(plan.windows[0].start_ms, now); + assert.equal(plan.windows.at(-1).end_ms, now + 24 * hour); + assert.equal(plan.wh, 28000); assert.equal(plan.approximate, true); + const invalid = chargingPlan({ ...car, plan_windows: [{ start_ms: now, end_ms: now, wh: 5 }] }, now); + assert.equal(invalid.windows.length, 0); +}); + +test('unavailable, manual, complete and updating states hide old windows', () => { + for (const change of [ + { manual_active: true }, { manual_active: true, manual_charge_w: 0 }, + { goal_complete: true }, { plan_pending: true }, { plan_outdated: true }, + { surplus_only: true }, { grid_deferred: true }, { plugged_in: false }, + { charger: { available: false } }, { power_unavailable: true }, + ]) { + const plan = chargingPlan({ ...car, plan_windows: windows, ...change }, now); + assert.equal(plan.windows.length, 0, JSON.stringify(change)); assert.ok(plan.message); + } + assert.match(chargingPlan(null, now).message, /unavailable/); + assert.match(chargingPlan(car, now).message, /No charging planned/); +}); + +class Element { + constructor(tag) { this.tag = tag; this.children = []; this.style = {}; this.events = {}; this.attrs = {}; this.textContent = ''; } + appendChild(el) { el.parentNode = this; this.children.push(el); return el; } + insertBefore(el, ref) { el.parentNode = this; this.children.splice(this.children.indexOf(ref),0,el); } + replaceChild(el, old) { el.parentNode=this; this.children.splice(this.children.indexOf(old),1,el); } + removeChild(el) { this.children.splice(this.children.indexOf(el),1); } + replaceChildren() { this.children=[]; } + setAttribute(k,v) { this.attrs[k]=v; } + addEventListener(k,fn) { (this.events[k]??=[]).push(fn); } + emit(k) { for(const fn of this.events[k]??[]) fn(); } +} +const doc = { createElement: tag => new Element(tag) }; +const descendants = el => [el,...el.children.flatMap(descendants)]; +const text = el => descendants(el).map(e=>e.textContent).join(' '); + +test('timeline renders accessible time blocks, then clears them on an outdated plan', () => { + const view = createChargingTimeline(doc); + view.update({ ...car, plan_windows: windows }, { start: now }); + assert.match(text(view.el), /28.0 kWh/); + assert.equal(descendants(view.el).filter(el=>el.className==='ev-plan-slot').length,3); + assert.equal(descendants(view.el).find(el=>el.className==='ev-plan-more').hidden,false); + view.update({ ...car, plan_windows: windows, plan_outdated: true }, { start: now }); + assert.match(text(view.el), /times unavailable/); + assert.equal(descendants(view.el).filter(el=>el.className==='ev-plan-slot').length,0); +}); + +test('mounted car view follows source changes and keeps the slider while editing', async () => { + const source=readFileSync(new URL('./app.js',import.meta.url),'utf8'); + const functions=source.slice(source.indexOf('function sliderHeader'),source.indexOf('function buildEvCapacityView')); + const api=new Function('document','evPlanUI','buildEvCapacityView','renderEvPlanStatus','evWrite','refreshEvModalAfterWrite',functions+';return buildEvPlanView;')( + doc, Promise.resolve({ chargingLevels, createChargingTimeline:()=>createChargingTimeline(doc) }), + ()=>({el:new Element('details'),update(){}}),()=>null,()=>Promise.resolve({ok:true,json:async()=>({ok:true})}),async()=>{}, + ); + const view=api(known,{}); await new Promise(r=>setImmediate(r)); + assert.match(text(view.el),/47% Reported by car/); + assert.equal(view.slider.parentNode.hidden,true); + view.update(car,{}); + assert.equal(view.slider.parentNode.hidden,false); + assert.equal(view.slider.value,'45'); + view.slider.value='55'; view.slider.emit('pointerdown'); + view.update({...car,current_soc:.46},{}); + assert.equal(view.slider.value,'55'); + view.slider.emit('pointerup'); view.update({...car,current_soc:.46},{}); + assert.equal(view.slider.value,'46'); +}); diff --git a/web/ev-plug-in-view.test.mjs b/web/ev-plug-in-view.test.mjs index 6a6ee5dc4..26c5797fc 100644 --- a/web/ev-plug-in-view.test.mjs +++ b/web/ev-plug-in-view.test.mjs @@ -11,16 +11,6 @@ const view = source.slice( // The plug-in moment (#1059): the modal shows what the box will do and // lets the car's charge level be corrected without a button. -test('the plan view draws the planned windows on a 24 h track', () => { - assert.match(view, /lpNow\.plan_windows/); - assert.match(view, /EV_PLAN_HORIZON_MS/); - // Every window is placed by wall clock and names its energy. - assert.match(view, /w\.start_ms/); - assert.match(view, /w\.wh \/ 1000/); - // A manual hold is explained instead of drawn as a plan. - assert.match(view, /Manual charge is selected/); -}); - test('the charge-level slider writes on release, with no button', () => { assert.match(view, /slider\.addEventListener\("change"/); assert.match(view, /\/soc"/); @@ -47,3 +37,13 @@ test('the plan view is mounted once per loadpoint and updated on polls', () => { assert.match(source, /evPlanLpId !== matched\.id/); assert.match(source, /evModalBody\.insertBefore\(evPlanEl\.el, statusTableEl\)/); }); + + +test('pending SoC write does not claim saved state or require re-entry', () => { + const note = new Function(source.slice(source.indexOf('function sourceNote'), source.indexOf('var socPending')) + '; return sourceNote;')(); + const lp = { soc_source: 'inferred', current_soc: .8 }; + assert.match(note({ ...lp, soc_retention: 'pending' }), /Saving this level/); + assert.doesNotMatch(note({ ...lp, soc_retention: 'pending' }), /must be entered again|could not be saved|keeps this level/); + assert.match(note({ ...lp, soc_retention: 'session' }), /keeps this level/); + assert.match(note({ ...lp, soc_retention: 'error' }), /could not be saved/); +}); diff --git a/web/ev-schedule-autosave.test.mjs b/web/ev-schedule-autosave.test.mjs index b10b7c8b1..c615ebde2 100644 --- a/web/ev-schedule-autosave.test.mjs +++ b/web/ev-schedule-autosave.test.mjs @@ -13,7 +13,7 @@ const sched = source.slice( test('every schedule control saves on change', () => { assert.doesNotMatch(source, /Set schedule|Update schedule/); - assert.match(sched, /targetSlider\.addEventListener\("change", scheduleSave\)/); + assert.match(sched, /targetSlider\.addEventListener\("change"/); assert.match(sched, /timeInp\.addEventListener\("change", scheduleSave\)/); assert.match(sched, /recCb\.addEventListener\("change"/); assert.match(sched, /surCb\.addEventListener\("change"/); diff --git a/web/ev-vehicle-limit-goal.test.mjs b/web/ev-vehicle-limit-goal.test.mjs new file mode 100644 index 000000000..d91aa176b --- /dev/null +++ b/web/ev-vehicle-limit-goal.test.mjs @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); +const controlsSource = source.slice(source.indexOf('function evIsPaused'), source.indexOf('// sliderHeader builds')) + + source.slice(source.indexOf('function sliderHeader'), source.indexOf('// buildEvPlanView')) + + source.slice(source.indexOf('function buildScheduleSection'), source.indexOf('var evRefreshTimer')); + +class Element { + constructor(tag) { this.tag = tag; this.children = []; this.style = {}; this.dataset = {}; this.attrs = {}; this.events = {}; this.textContent = ''; } + appendChild(el) { this.children.push(el); return el; } + setAttribute(k, v) { this.attrs[k] = v; } + addEventListener(k, fn) { (this.events[k] ??= []).push(fn); } + emit(k) { for (const fn of this.events[k] ?? []) fn(); } + cloneNode() { const copy = new Element(this.tag); copy.style = { ...this.style }; return copy; } +} +function descendants(el) { return [el, ...el.children.flatMap(descendants)]; } +const settle = () => new Promise(resolve => setImmediate(resolve)); +function fixture({ supported = true, schedule = { soc: .8, time_of_day_min_utc: 420, recurring: true }, post } = {}) { + const timers = new Map(), requests = []; + let timerID = 0; + const lp = { id: 'car/1', plugged_in: true, schedule, target_soc: 1 }; + const api = new Function('document', 'setTimeout', 'clearTimeout', 'evWrite', 'refreshEvModalAfterWrite', + controlsSource + ';return { buildEvControls, buildScheduleSection };')( + { createElement: tag => new Element(tag) }, + fn => { timers.set(++timerID, fn); return timerID; }, id => timers.delete(id), + (path, options) => { const request = { path, ...JSON.parse(options.body) }; requests.push(request); return post ? post(request) : Promise.resolve({ ok: true }); }, + () => {}, + ); + const root = api.buildEvControls(lp, false, supported); + const find = predicate => descendants(root).find(predicate); + return { + root, requests, lp, find, + mode: find(el => el.attrs['aria-label'] === 'Charge target'), + slider: find(el => el.attrs['aria-label'] === 'Target charge, percent'), + time: find(el => el.attrs['aria-label'] === 'Ready by'), + text: () => descendants(root).map(el => el.textContent).join('\n'), + async flush() { for (const [id, fn] of timers) { timers.delete(id); fn(); } await settle(); }, + }; +} + +test('existing percent goal stays unchanged on open and a time edit', async () => { + const ui = fixture({ schedule: { soc: .73, time_of_day_min_utc: 420, recurring: true } }); + assert.equal(ui.mode.value, 'percent'); + assert.equal(ui.slider.hidden, false); + await ui.flush(); + assert.equal(ui.requests.length, 0); + ui.time.value = '08:15'; ui.time.emit('change'); await ui.flush(); + assert.equal(ui.requests[0].schedule.soc, .73); + assert.equal(ui.requests[0].schedule.finish_at_vehicle_limit, false); +}); + +test('car-limit selection preserves percent and selected weekdays', async () => { + const ui = fixture({ schedule: { soc: .85, time_of_day_min_utc: 420, recurring: true, days: 31 } }); + ui.mode.value = 'vehicle'; ui.mode.emit('change'); + assert.equal(ui.slider.hidden, true); + assert.equal(ui.slider.disabled, true); + const details = ui.find(el => el.tag === 'details' && el.children[0]?.textContent === 'How this goal works'); + assert.equal(details.hidden, false); + assert.notEqual(details.open, true); + await ui.flush(); + assert.equal(ui.requests[0].path, '/api/loadpoints/car%2F1/target'); + assert.equal(ui.requests[0].schedule.finish_at_vehicle_limit, true); + assert.equal(ui.requests[0].schedule.soc, .85); + assert.equal(ui.requests[0].schedule.days, 31); + ui.mode.value = 'percent'; ui.mode.emit('change'); await ui.flush(); + assert.equal(ui.requests[1].schedule.finish_at_vehicle_limit, false); + assert.equal(ui.requests[1].schedule.soc, .85); + ui.slider.value = '90'; ui.slider.emit('change'); await ui.flush(); + assert.equal(ui.requests[2].schedule.soc, .9); +}); + +test('only an explicit true support flag exposes the choice', async () => { + for (const supported of [false, undefined, 'true', 1]) { + const ui = fixture({ supported: supported === undefined ? null : supported }); + assert.equal(ui.mode, undefined); + ui.time.value = '08:15'; ui.time.emit('change'); await ui.flush(); + assert.equal('finish_at_vehicle_limit' in ui.requests[0].schedule, false); + assert.equal(ui.requests[0].schedule.soc, .8); + } +}); + +test('a car-limit goal with no percent is a saved goal, not runtime 100 percent', async () => { + const ui = fixture({ schedule: { soc: 0, finish_at_vehicle_limit: true, time_of_day_min_utc: 420 } }); + assert.equal(ui.mode.value, 'vehicle'); + assert.equal(ui.slider.hidden, true); + assert.match(ui.text(), /Car's charge limit by/); + assert.doesNotMatch(ui.text(), /100 % by|No ready time set|No goal set yet/); + assert.equal(ui.find(el => el.textContent === 'Remove schedule').hidden, false); + ui.time.value = '08:15'; ui.time.emit('change'); await ui.flush(); + assert.equal(ui.requests[0].schedule.finish_at_vehicle_limit, true); + assert.equal(ui.requests[0].schedule.soc, 0); +}); + +test('a failed save stays unconfirmed and does not change the saved summary', async () => { + const ui = fixture({ post: async () => ({ ok: false, status: 500, json: async () => ({ error: 'Could not save goal' }) }) }); + ui.mode.value = 'vehicle'; ui.mode.emit('change'); + assert.match(ui.text(), /Applying schedule/); + await ui.flush(); + assert.match(ui.text(), /Schedule not confirmed: Could not save goal/); + assert.match(ui.text(), /80 % by/); + assert.doesNotMatch(ui.text(), /Schedule saved/); +}); + +test('quick mode edits only save the last choice', async () => { + const ui = fixture(); + ui.mode.value = 'vehicle'; ui.mode.emit('change'); + ui.mode.value = 'percent'; ui.mode.emit('change'); + await ui.flush(); + assert.equal(ui.requests.length, 1); + assert.equal(ui.requests[0].schedule.finish_at_vehicle_limit, false); +}); + +test('Charge now sends a manual hold without a percent release in car-limit mode', async () => { + const ui = fixture({ schedule: { soc: .8, finish_at_vehicle_limit: true } }); + ui.find(el => el.textContent === 'Charge now').emit('click'); await settle(); + assert.equal(ui.requests[0].path, '/api/loadpoints/car%2F1/manual_hold'); + assert.equal(ui.requests[0].hold_s, 0); + assert.equal(ui.requests[0].power_w, 11040); + assert.equal('release_at_soc_pct' in ui.requests[0], false); +}); + + +test('session write failure is visible without opening the goal editor', () => { + const ui = fixture({ schedule: { soc: .8, finish_at_vehicle_limit: true } }); + const error = ui.find(el => el.textContent.startsWith('FTW could not save this charging session.')); + assert.equal(error.hidden, true); + ui.root.update({ ...ui.lp, goal_retention: 'error' }, null); + assert.equal(error.hidden, false); + assert.match(error.textContent, /Your schedule is still saved/); + assert.equal(ui.find(el => el.tag === 'section').children.includes(error), true); + ui.root.update({ ...ui.lp, goal_retention: 'session' }, null); + assert.equal(error.hidden, true); +}); + +test('unverified session identity stays in collapsed goal details', () => { + const ui = fixture({ schedule: { soc: .8, finish_at_vehicle_limit: true } }); + ui.root.update({ ...ui.lp, goal_retention: 'unavailable' }, null); + const details = ui.find(el => el.tag === 'details' && el.children[0]?.textContent === 'How this goal works'); + assert.notEqual(details.open, true); + assert.match(details.children.map(el => el.textContent).join(' '), /cannot identify this charging session/); + assert.equal(ui.find(el => el.textContent.startsWith('FTW could not save')).hidden, true); +}); + + +test('completed one-shot is labeled complete and server-owned identity stays out of writes', async () => { + const ui = fixture({ schedule: { soc: .8, finish_at_vehicle_limit: true, recurring: false, + time_of_day_min_utc: 420, intent_id: 'server-owned', first_deadline_ms: 1790000000000 } }); + ui.root.update({ ...ui.lp, goal_complete: true, target_soc: 0 }, null); + assert.match(ui.text(), /Car's charge limit by .* · completed/); + ui.time.value = '08:15'; ui.time.emit('change'); await ui.flush(); + assert.equal('intent_id' in ui.requests[0].schedule, false); + assert.equal('first_deadline_ms' in ui.requests[0].schedule, false); + assert.equal(ui.requests[0].schedule.finish_at_vehicle_limit, true); +}); + + +test('recurring goal follows Core completion and returns to repeats when Core reopens it', () => { + const ui = fixture({ schedule: { soc: .8, finish_at_vehicle_limit: true, recurring: true, time_of_day_min_utc: 420 } }); + ui.root.update({ ...ui.lp, goal_complete: true }, null); + assert.match(ui.text(), /Car's charge limit by .* · completed/); + ui.root.update({ ...ui.lp, goal_complete: false }, null); + assert.match(ui.text(), /Car's charge limit by .* · repeats/); +}); + + +test('pending goal write is saving, not failed or confirmed saved', () => { + const ui = fixture({ schedule: { soc: .8, finish_at_vehicle_limit: true } }); + ui.root.update({ ...ui.lp, goal_retention: 'pending' }, null); + const details = ui.find(el => el.tag === 'details' && el.children[0]?.textContent === 'How this goal works'); + assert.notEqual(details.open, true); + const note = details.children.at(-1); + assert.match(note.textContent, /Saving this session’s goal/); + assert.equal(ui.find(el => el.textContent.startsWith('FTW could not save')).hidden, true); + ui.root.update({ ...ui.lp, goal_retention: 'session' }, null); + assert.match(note.textContent, /goal is saved and can be restored/); +}); diff --git a/web/index.html b/web/index.html index c78a8ab8d..23df4192b 100644 --- a/web/index.html +++ b/web/index.html @@ -234,6 +234,7 @@
Waiting for planner state.
Safety limits remain active.
+ Open full plan @@ -542,6 +543,8 @@- Forecast-driven battery schedule for the next 48 h, recomputed every few minutes. + Forecast-driven schedule for the home battery and cars over the next 48 h, updated every few minutes. Each bar is a 15-minute slot. Prices after the day-ahead publication window are filled in by the ML price twin. Hover any slot for the full breakdown. The dashed vertical line marks “now”.
@@ -618,6 +621,7 @@