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 @@