Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/vehicle-charge-limit-ui.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
267 changes: 166 additions & 101 deletions web/app.js

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions web/ev-manual-feedback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
40 changes: 39 additions & 1 deletion web/ev-plan-status.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand All @@ -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/);
});
118 changes: 118 additions & 0 deletions web/ev-plan.js
Original file line number Diff line number Diff line change
@@ -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`;
} };
}
Loading