diff --git a/Dockerfile b/Dockerfile index f1b1347..1396a7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,18 +4,25 @@ ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_NO_CACHE_DIR=1 \ - PORT=8080 + PYTHONPATH=/app/src WORKDIR /app -COPY pyproject.toml uv.lock README.md ./ -COPY src ./src - +# Install dependencies only (not the package itself), so the source tree at +# /app stays the runtime layout the app expects: web.py uses +# Path(__file__).resolve().parents[2] / "data" / "raw" to find CSVs, which +# only resolves correctly when the package is imported from /app/src. +COPY pyproject.toml uv.lock ./ RUN pip install --no-cache-dir uv \ - && uv pip install --system --no-cache . + && uv pip install --system --no-cache \ + "pandas>=2.2" "scipy>=1.13" "numpy>=2.0" "flask>=3.0" "gunicorn>=21.0" +COPY src ./src COPY data ./data EXPOSE 8080 -CMD ["sh", "-c", "gunicorn pluginbattery.web:app --bind 0.0.0.0:${PORT:-8080} --workers 1 --timeout 120"] +CMD ["gunicorn", "pluginbattery.web:app", \ + "--bind", "0.0.0.0:8080", \ + "--workers", "1", \ + "--timeout", "120"] diff --git a/src/pluginbattery/static/app.js b/src/pluginbattery/static/app.js index 99cfb96..f3938e8 100644 --- a/src/pluginbattery/static/app.js +++ b/src/pluginbattery/static/app.js @@ -9,14 +9,37 @@ const tbody = document.getElementById("leaderboard-body"); const countEl = document.getElementById("count"); const elapsed = document.getElementById("elapsed"); const bestEl = document.getElementById("best-content"); -const pluginOnly = document.getElementById("plugin-only"); +const filterBoxes = Array.from(document.querySelectorAll('.filter-toggle input[data-cat]')); +const FILTER_KEY = "pluginbattery_categories"; -const PLUGIN_POWER_LIMIT_KW = 0.8; -const FILTER_KEY = "pluginbattery_plugin_only"; - -// Latest fetched dataset; the filter toggle re-paints from this. +// Latest fetched dataset; the filter toggles re-paint from this. let lastBatteries = null; +function categorize(b) { + // Use the category provided by the API, or fall back to power-class for the + // SSR-captured rows which only have power_kw. + if (b.category) return b.category; + if (b.allows_export) return "hybrid"; + return b.power_kw <= 0.8 + 1e-9 ? "small_plugin" : "large_plugin"; +} + +function selectedCategories() { + return new Set(filterBoxes.filter((c) => c.checked).map((c) => c.dataset.cat)); +} + +function persistFilter() { + localStorage.setItem(FILTER_KEY, JSON.stringify([...selectedCategories()])); +} + +function restoreFilter() { + try { + const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || "null"); + if (Array.isArray(saved)) { + filterBoxes.forEach((c) => { c.checked = saved.includes(c.dataset.cat); }); + } + } catch (_) { /* keep defaults */ } +} + function fmtNum(x, decimals = 2) { return x === null || x === undefined ? "—" : Number(x).toFixed(decimals); } @@ -104,8 +127,9 @@ function renderTable(rows) { } function applyFilter(batteries) { - if (!pluginOnly.checked) return batteries; - return batteries.filter((b) => b.power_kw <= PLUGIN_POWER_LIMIT_KW + 1e-9); + const cats = selectedCategories(); + if (cats.size === 0) return []; // nothing checked → show nothing + return batteries.filter((b) => cats.has(categorize(b))); } function repaint() { @@ -150,29 +174,27 @@ function captureInitialFromDOM() { lastBatteries = rows.map((tr) => { const cells = tr.children; const link = cells[1].querySelector("a"); + const storeText = cells[7].textContent.trim(); return { - title: (link ? link.textContent : cells[1].textContent).trim(), - url: link ? link.href : "", + category: tr.dataset.category || null, + title: (link ? link.textContent : cells[1].textContent).trim(), + url: link ? link.href : "", capacity_kwh: parseFloat(cells[2].textContent), power_kw: parseFloat(cells[3].textContent), 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), - store_year1: 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), }; }); } -// Restore filter state from localStorage and wire up listeners. -pluginOnly.checked = localStorage.getItem(FILTER_KEY) === "true"; -pluginOnly.addEventListener("change", () => { - localStorage.setItem(FILTER_KEY, pluginOnly.checked); - repaint(); -}); +restoreFilter(); +filterBoxes.forEach((c) => c.addEventListener("change", () => { persistFilter(); repaint(); })); captureInitialFromDOM(); -if (pluginOnly.checked) repaint(); // honour saved filter on load +repaint(); // honour saved filter 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 897771f..454c4f3 100644 --- a/src/pluginbattery/static/style.css +++ b/src/pluginbattery/static/style.css @@ -125,12 +125,17 @@ section#results { .card h2 { margin: 0 0 1rem; font-size: 1rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); } .card h2 small { font-weight: 400; text-transform: none; opacity: 0.8; margin-left: 0.5rem; } +.filters { + display: flex; + gap: 1.25rem; + flex-wrap: wrap; + margin-bottom: 0.85rem; + font-size: 0.85rem; +} .filter-toggle { display: inline-flex; align-items: center; gap: 0.4rem; - margin-left: 1rem; - font-size: 0.8rem; font-weight: 400; text-transform: none; letter-spacing: 0; diff --git a/src/pluginbattery/templates/index.html b/src/pluginbattery/templates/index.html index 93e0796..3861725 100644 --- a/src/pluginbattery/templates/index.html +++ b/src/pluginbattery/templates/index.html @@ -4,7 +4,7 @@
| {{ loop.index }} | {{ r.title }} | {{ '%.2f' % r.capacity_kwh }} | @@ -108,7 +112,7 @@€{{ '%.0f' % r.price_eur }} | €{{ '%.0f' % r.lp_year1 }} | {% if r.lp_payback %}{{ '%.1f' % r.lp_payback }}{% else %}—{% endif %} | -€{{ '%.0f' % r.store_year1 }} | +{% 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 %} |