From d6a3d53a043c7b7f403980d5cdcee44b52a90dca Mon Sep 17 00:00:00 2001 From: Michiel Berger Date: Thu, 30 Apr 2026 15:41:11 +0200 Subject: [PATCH] Add 10-yr net profit column + clickable column sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: every row now carries ten_year_profit_eur = year1 × cumulative inflation factor − battery cost. Negative for batteries that don't pay back within 10 years (the Powerwall). - New column shows green for positive net, red for negative. - Every column header is now clickable: click to sort, click again to toggle direction. Sort indicator (↑/↓) on the active header. - Sort + filter selections persist in localStorage independently. - Default sort stays 'LP yr ascending' (best payback first), but a single click on '10-yr net' flips to absolute lifetime profit and larger batteries surface to the top. --- src/pluginbattery/static/app.js | 77 ++++++++++++++++++++++---- src/pluginbattery/static/style.css | 7 +++ src/pluginbattery/templates/index.html | 24 ++++---- src/pluginbattery/web.py | 13 +++++ 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/src/pluginbattery/static/app.js b/src/pluginbattery/static/app.js index f3938e8..7822602 100644 --- a/src/pluginbattery/static/app.js +++ b/src/pluginbattery/static/app.js @@ -10,10 +10,13 @@ const countEl = document.getElementById("count"); const elapsed = document.getElementById("elapsed"); const bestEl = document.getElementById("best-content"); const filterBoxes = Array.from(document.querySelectorAll('.filter-toggle input[data-cat]')); +const sortHeaders = Array.from(document.querySelectorAll('thead th[data-sort]')); const FILTER_KEY = "pluginbattery_categories"; +const SORT_KEY = "pluginbattery_sort"; -// Latest fetched dataset; the filter toggles re-paint from this. +// Latest fetched dataset; toggles + sorts re-paint from this. let lastBatteries = null; +let sortState = { key: "lp_payback", dir: "asc" }; function categorize(b) { // Use the category provided by the API, or fall back to power-class for the @@ -90,9 +93,15 @@ function renderBest(b) { dl.append( dt("Annual savings (year-1)"), dd(`€${fmtNum(b.lp_year1, 0)}/yr`), dt("Payback"), dd(`${fmtPayback(b.lp_payback)} years`), + dt("10-yr net profit"), dd(`€${fmtNum(b.ten_year_profit_eur, 0)}`), dt("Battery"), dd(`${fmtNum(b.capacity_kwh)} kWh / ${fmtNum(b.power_kw, 1)} kW · €${fmtNum(b.price_eur, 0)}`), - dt("Store quote"), dd(`€${fmtNum(b.store_year1, 0)}/yr (${fmtPayback(b.store_payback)} yr) — overstates by ${fmtNum(b.overstatement)}×`), ); + if (b.store_year1 != null) { + dl.append( + dt("Store quote"), + dd(`€${fmtNum(b.store_year1, 0)}/yr (${fmtPayback(b.store_payback)} yr) — overstates by ${fmtNum(b.overstatement)}×`), + ); + } bestEl.appendChild(dl); } @@ -112,15 +121,17 @@ function renderTable(rows) { } tr.appendChild(tdTitle); + const tenYrCls = r.ten_year_profit_eur < 0 ? "neg" : "pos"; tr.append( td(fmtNum(r.capacity_kwh)), td(fmtNum(r.power_kw, 1)), td(`€${fmtNum(r.price_eur, 0)}`), td(`€${fmtNum(r.lp_year1, 0)}`), td(fmtPayback(r.lp_payback)), - td(`€${fmtNum(r.store_year1, 0)}`), + td(`€${fmtNum(r.ten_year_profit_eur, 0)}`, { className: tenYrCls }), + td(r.store_year1 == null ? "—" : `€${fmtNum(r.store_year1, 0)}`), td(fmtPayback(r.store_payback)), - td(r.overstatement === null ? "—" : `${fmtNum(r.overstatement)}×`), + td(r.overstatement == null ? "—" : `${fmtNum(r.overstatement)}×`), ); tbody.appendChild(tr); }); @@ -132,12 +143,44 @@ function applyFilter(batteries) { return batteries.filter((b) => cats.has(categorize(b))); } +function applySort(rows) { + const { key, dir } = sortState; + const mult = dir === "asc" ? 1 : -1; + return rows.slice().sort((a, b) => { + const av = a[key]; const bv = b[key]; + // Nulls always sort to the bottom regardless of direction. + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + if (typeof av === "string") return av.localeCompare(bv) * mult; + return (av - bv) * mult; + }); +} + +function updateSortIndicators() { + sortHeaders.forEach((th) => { + th.classList.remove("sort-asc", "sort-desc"); + if (th.dataset.sort === sortState.key) { + th.classList.add(sortState.dir === "asc" ? "sort-asc" : "sort-desc"); + } + }); +} + function repaint() { if (!lastBatteries) return; - const visible = applyFilter(lastBatteries); + const visible = applySort(applyFilter(lastBatteries)); renderBest(visible[0] || null); renderTable(visible); countEl.textContent = visible.length; + updateSortIndicators(); +} + +function persistSort() { localStorage.setItem(SORT_KEY, JSON.stringify(sortState)); } +function restoreSort() { + try { + const s = JSON.parse(localStorage.getItem(SORT_KEY) || "null"); + if (s && s.key) sortState = s; + } catch (_) { /* keep default */ } } async function recalc() { @@ -167,14 +210,14 @@ async function recalc() { } } -// Capture the SSR-rendered table into lastBatteries so the filter works +// Capture the SSR-rendered table into lastBatteries so filter+sort work // before the user clicks Recalculate. function captureInitialFromDOM() { const rows = Array.from(tbody.querySelectorAll("tr")); lastBatteries = rows.map((tr) => { const cells = tr.children; const link = cells[1].querySelector("a"); - const storeText = cells[7].textContent.trim(); + const storeText = cells[8].textContent.trim(); return { category: tr.dataset.category || null, title: (link ? link.textContent : cells[1].textContent).trim(), @@ -184,17 +227,31 @@ function captureInitialFromDOM() { price_eur: parseFloat(cells[4].textContent.replace("€", "")), lp_year1: parseFloat(cells[5].textContent.replace("€", "")), lp_payback: cells[6].textContent.trim() === "—" ? null : parseFloat(cells[6].textContent), + ten_year_profit_eur: parseFloat(cells[7].textContent.replace("€", "")), store_year1: storeText === "—" ? null : parseFloat(storeText.replace("€", "")), - store_payback: cells[8].textContent.trim() === "—" ? null : parseFloat(cells[8].textContent), - overstatement: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent), + store_payback: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent), + overstatement: cells[10].textContent.trim() === "—" ? null : parseFloat(cells[10].textContent), }; }); } restoreFilter(); +restoreSort(); filterBoxes.forEach((c) => c.addEventListener("change", () => { persistFilter(); repaint(); })); +sortHeaders.forEach((th) => { + th.addEventListener("click", () => { + const key = th.dataset.sort; + if (sortState.key === key) { + sortState.dir = sortState.dir === "asc" ? "desc" : "asc"; + } else { + sortState = { key, dir: th.dataset.defaultDir || "desc" }; + } + persistSort(); + repaint(); + }); +}); captureInitialFromDOM(); -repaint(); // honour saved filter on first paint +repaint(); // honour saved filter + sort on first paint form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); }); diff --git a/src/pluginbattery/static/style.css b/src/pluginbattery/static/style.css index 454c4f3..35c2370 100644 --- a/src/pluginbattery/static/style.css +++ b/src/pluginbattery/static/style.css @@ -180,7 +180,14 @@ table#leaderboard thead th { border-bottom: 2px solid var(--border); cursor: help; font-weight: 600; + user-select: none; } +table#leaderboard thead th[data-sort] { cursor: pointer; } +table#leaderboard thead th[data-sort]:hover { color: var(--accent); } +table#leaderboard thead th.sort-asc::after { content: " ↑"; color: var(--accent); } +table#leaderboard thead th.sort-desc::after { content: " ↓"; color: var(--accent); } +table#leaderboard td.pos { color: var(--accent); } +table#leaderboard td.neg { color: var(--warn); } table#leaderboard tbody tr:hover { background: var(--panel2); } table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); } diff --git a/src/pluginbattery/templates/index.html b/src/pluginbattery/templates/index.html index 3861725..84c605d 100644 --- a/src/pluginbattery/templates/index.html +++ b/src/pluginbattery/templates/index.html @@ -4,7 +4,7 @@ Honest battery payback — vs thuisbatterijgids.nl - + @@ -91,15 +91,16 @@ # - Battery - kWh - kW - € - LP €/yr - LP yr - Store €/yr - Store yr - ×over + Battery + kWh + kW + € + LP €/yr + LP yr + 10-yr net + Store €/yr + Store yr + ×over @@ -112,6 +113,7 @@ €{{ '%.0f' % r.price_eur }} €{{ '%.0f' % r.lp_year1 }} {% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %} + €{{ '%.0f' % r.ten_year_profit_eur }} {% if r.store_year1 is not none %}€{{ '%.0f' % r.store_year1 }}{% else %}—{% endif %} {% if r.store_payback %}{{ '%.1f' % r.store_payback }}{% else %}—{% endif %} {% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %} @@ -132,6 +134,6 @@

- + diff --git a/src/pluginbattery/web.py b/src/pluginbattery/web.py index 4cc7fd4..2bbcd30 100644 --- a/src/pluginbattery/web.py +++ b/src/pluginbattery/web.py @@ -78,6 +78,18 @@ def _category(power_kw: float, allows_export: bool) -> str: return "hybrid" return "small_plugin" if power_kw <= 0.8 + 1e-9 else "large_plugin" + +def _lifetime_profit(year1: float, cost: float, years: int, inflation: float) -> float: + """Cumulative savings over `years` minus the up-front battery cost. + + Each successive year's savings are inflated by `inflation` (matches the + payback formula). Negative result = the battery doesn't break even + within the horizon. + """ + if inflation == 0: + return year1 * years - cost + return year1 * ((1 + inflation) ** years - 1) / inflation - cost + print(f" {len(_BASE_DF)} hourly rows, {len(_CATALOG)} batteries in catalog.", flush=True) # scenario hash → result dict @@ -231,6 +243,7 @@ def compute_leaderboard( "allows_export": allows_export, "lp_year1": round(lp_year1, 2), "lp_payback": round(lp_payback, 2) if np.isfinite(lp_payback) else None, + "ten_year_profit_eur": round(_lifetime_profit(lp_year1, price, 10, inflation), 2), "store_year1": store_y1, "store_payback": store_pay, "overstatement": overstate,