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:
parent
386a758517
commit
d6a3d53a04
4 changed files with 100 additions and 21 deletions
|
|
@ -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(); });
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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>
|
||||
<body>
|
||||
|
||||
|
|
@ -91,15 +91,16 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Battery</th>
|
||||
<th>kWh</th>
|
||||
<th>kW</th>
|
||||
<th>€</th>
|
||||
<th title="Honest year-1 savings (our LP)">LP €/yr</th>
|
||||
<th title="Honest payback with inflation">LP yr</th>
|
||||
<th title="What the store advertises">Store €/yr</th>
|
||||
<th title="Store-advertised payback">Store yr</th>
|
||||
<th title="Store / LP — how much they overstate">×over</th>
|
||||
<th data-sort="title" data-default-dir="asc">Battery</th>
|
||||
<th data-sort="capacity_kwh" data-default-dir="desc">kWh</th>
|
||||
<th data-sort="power_kw" data-default-dir="desc">kW</th>
|
||||
<th data-sort="price_eur" data-default-dir="asc">€</th>
|
||||
<th data-sort="lp_year1" data-default-dir="desc" title="Honest year-1 savings (our LP)">LP €/yr</th>
|
||||
<th data-sort="lp_payback" data-default-dir="asc" title="Honest payback with inflation">LP 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 data-sort="store_year1" data-default-dir="desc" title="What the store advertises">Store €/yr</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>
|
||||
</thead>
|
||||
<tbody id="leaderboard-body">
|
||||
|
|
@ -112,6 +113,7 @@
|
|||
<td>€{{ '%.0f' % r.price_eur }}</td>
|
||||
<td>€{{ '%.0f' % r.lp_year1 }}</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_payback %}{{ '%.1f' % r.store_payback }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.overstatement %}{{ '%.2f' % r.overstatement }}×{% else %}—{% endif %}</td>
|
||||
|
|
@ -132,6 +134,6 @@
|
|||
</p>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='app.js') }}?v=6"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue