Add 10-yr net profit column + clickable column sorting

- 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.
This commit is contained in:
Michiel Berger 2026-04-30 15:41:11 +02:00
parent 386a758517
commit d6a3d53a04
4 changed files with 100 additions and 21 deletions

View file

@ -10,10 +10,13 @@ const countEl = document.getElementById("count");
const elapsed = document.getElementById("elapsed"); const elapsed = document.getElementById("elapsed");
const bestEl = document.getElementById("best-content"); const bestEl = document.getElementById("best-content");
const filterBoxes = Array.from(document.querySelectorAll('.filter-toggle input[data-cat]')); 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 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 lastBatteries = null;
let sortState = { key: "lp_payback", dir: "asc" };
function categorize(b) { function categorize(b) {
// Use the category provided by the API, or fall back to power-class for the // Use the category provided by the API, or fall back to power-class for the
@ -90,9 +93,15 @@ function renderBest(b) {
dl.append( dl.append(
dt("Annual savings (year-1)"), dd(`${fmtNum(b.lp_year1, 0)}/yr`), dt("Annual savings (year-1)"), dd(`${fmtNum(b.lp_year1, 0)}/yr`),
dt("Payback"), dd(`${fmtPayback(b.lp_payback)} years`), 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("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); bestEl.appendChild(dl);
} }
@ -112,15 +121,17 @@ function renderTable(rows) {
} }
tr.appendChild(tdTitle); tr.appendChild(tdTitle);
const tenYrCls = r.ten_year_profit_eur < 0 ? "neg" : "pos";
tr.append( tr.append(
td(fmtNum(r.capacity_kwh)), td(fmtNum(r.capacity_kwh)),
td(fmtNum(r.power_kw, 1)), td(fmtNum(r.power_kw, 1)),
td(`${fmtNum(r.price_eur, 0)}`), td(`${fmtNum(r.price_eur, 0)}`),
td(`${fmtNum(r.lp_year1, 0)}`), td(`${fmtNum(r.lp_year1, 0)}`),
td(fmtPayback(r.lp_payback)), 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(fmtPayback(r.store_payback)),
td(r.overstatement === null ? "—" : `${fmtNum(r.overstatement)}×`), td(r.overstatement == null ? "—" : `${fmtNum(r.overstatement)}×`),
); );
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@ -132,12 +143,44 @@ function applyFilter(batteries) {
return batteries.filter((b) => cats.has(categorize(b))); 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() { function repaint() {
if (!lastBatteries) return; if (!lastBatteries) return;
const visible = applyFilter(lastBatteries); const visible = applySort(applyFilter(lastBatteries));
renderBest(visible[0] || null); renderBest(visible[0] || null);
renderTable(visible); renderTable(visible);
countEl.textContent = visible.length; 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() { 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. // before the user clicks Recalculate.
function captureInitialFromDOM() { function captureInitialFromDOM() {
const rows = Array.from(tbody.querySelectorAll("tr")); const rows = Array.from(tbody.querySelectorAll("tr"));
lastBatteries = rows.map((tr) => { lastBatteries = rows.map((tr) => {
const cells = tr.children; const cells = tr.children;
const link = cells[1].querySelector("a"); const link = cells[1].querySelector("a");
const storeText = cells[7].textContent.trim(); const storeText = cells[8].textContent.trim();
return { return {
category: tr.dataset.category || null, category: tr.dataset.category || null,
title: (link ? link.textContent : cells[1].textContent).trim(), title: (link ? link.textContent : cells[1].textContent).trim(),
@ -184,17 +227,31 @@ function captureInitialFromDOM() {
price_eur: parseFloat(cells[4].textContent.replace("€", "")), price_eur: parseFloat(cells[4].textContent.replace("€", "")),
lp_year1: parseFloat(cells[5].textContent.replace("€", "")), lp_year1: parseFloat(cells[5].textContent.replace("€", "")),
lp_payback: cells[6].textContent.trim() === "—" ? null : parseFloat(cells[6].textContent), 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_year1: storeText === "—" ? null : parseFloat(storeText.replace("€", "")),
store_payback: cells[8].textContent.trim() === "—" ? null : parseFloat(cells[8].textContent), store_payback: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent),
overstatement: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent), overstatement: cells[10].textContent.trim() === "—" ? null : parseFloat(cells[10].textContent),
}; };
}); });
} }
restoreFilter(); restoreFilter();
restoreSort();
filterBoxes.forEach((c) => c.addEventListener("change", () => { persistFilter(); repaint(); })); 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(); captureInitialFromDOM();
repaint(); // honour saved filter on first paint repaint(); // honour saved filter + sort on first paint
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); }); form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); });

View file

@ -180,7 +180,14 @@ table#leaderboard thead th {
border-bottom: 2px solid var(--border); border-bottom: 2px solid var(--border);
cursor: help; cursor: help;
font-weight: 600; 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:hover { background: var(--panel2); }
table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); } table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); }

View file

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Honest battery payback — vs thuisbatterijgids.nl</title> <title>Honest battery payback — vs thuisbatterijgids.nl</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=6"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=7">
</head> </head>
<body> <body>
@ -91,15 +91,16 @@
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Battery</th> <th data-sort="title" data-default-dir="asc">Battery</th>
<th>kWh</th> <th data-sort="capacity_kwh" data-default-dir="desc">kWh</th>
<th>kW</th> <th data-sort="power_kw" data-default-dir="desc">kW</th>
<th></th> <th data-sort="price_eur" data-default-dir="asc"></th>
<th title="Honest year-1 savings (our LP)">LP €/yr</th> <th data-sort="lp_year1" data-default-dir="desc" title="Honest year-1 savings (our LP)">LP €/yr</th>
<th title="Honest payback with inflation">LP yr</th> <th data-sort="lp_payback" data-default-dir="asc" title="Honest payback with inflation">LP yr</th>
<th title="What the store advertises">Store €/yr</th> <th data-sort="ten_year_profit_eur" data-default-dir="desc" title="Cumulative savings over 10 yrs (with inflation) minus the battery cost">10-yr net</th>
<th title="Store-advertised payback">Store yr</th> <th data-sort="store_year1" data-default-dir="desc" title="What the store advertises">Store €/yr</th>
<th title="Store / LP — how much they overstate">×over</th> <th data-sort="store_payback" data-default-dir="asc" title="Store-advertised payback">Store yr</th>
<th data-sort="overstatement" data-default-dir="desc" title="Store / LP — how much they overstate">×over</th>
</tr> </tr>
</thead> </thead>
<tbody id="leaderboard-body"> <tbody id="leaderboard-body">
@ -112,6 +113,7 @@
<td>€{{ '%.0f' % r.price_eur }}</td> <td>€{{ '%.0f' % r.price_eur }}</td>
<td>€{{ '%.0f' % r.lp_year1 }}</td> <td>€{{ '%.0f' % r.lp_year1 }}</td>
<td>{% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %}</td> <td>{% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %}</td>
<td class="{% if r.ten_year_profit_eur < 0 %}neg{% else %}pos{% endif %}">€{{ '%.0f' % r.ten_year_profit_eur }}</td>
<td>{% if r.store_year1 is not none %}€{{ '%.0f' % r.store_year1 }}{% else %}—{% endif %}</td> <td>{% if r.store_year1 is not none %}€{{ '%.0f' % r.store_year1 }}{% else %}—{% endif %}</td>
<td>{% if r.store_payback %}{{ '%.1f' % r.store_payback }}{% else %}—{% endif %}</td> <td>{% if r.store_payback %}{{ '%.1f' % r.store_payback }}{% else %}—{% endif %}</td>
<td>{% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %}</td> <td>{% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %}</td>
@ -132,6 +134,6 @@
</p> </p>
</footer> </footer>
<script src="{{ url_for('static', filename='app.js') }}?v=6"></script> <script src="{{ url_for('static', filename='app.js') }}?v=7"></script>
</body> </body>
</html> </html>

View file

@ -78,6 +78,18 @@ def _category(power_kw: float, allows_export: bool) -> str:
return "hybrid" return "hybrid"
return "small_plugin" if power_kw <= 0.8 + 1e-9 else "large_plugin" 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) print(f" {len(_BASE_DF)} hourly rows, {len(_CATALOG)} batteries in catalog.", flush=True)
# scenario hash → result dict # scenario hash → result dict
@ -231,6 +243,7 @@ def compute_leaderboard(
"allows_export": allows_export, "allows_export": allows_export,
"lp_year1": round(lp_year1, 2), "lp_year1": round(lp_year1, 2),
"lp_payback": round(lp_payback, 2) if np.isfinite(lp_payback) else None, "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_year1": store_y1,
"store_payback": store_pay, "store_payback": store_pay,
"overstatement": overstate, "overstatement": overstate,