From 1d7574e0b2aebc303aa60eb4f4dd0be462ce32fa Mon Sep 17 00:00:00 2001 From: Michiel Berger Date: Thu, 30 Apr 2026 14:58:52 +0200 Subject: [PATCH] Add plug-in-only filter to web app + deploy script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Checkbox 'plug-in only (≤ 0.8 kW, no electrician)' filters the leaderboard client-side and re-picks the top recommendation from visible rows. Filter state is persisted in localStorage. - scripts/deploy.sh: idempotent rsync + systemd setup against the 'thuisbatterij' SSH alias. Run after provisioning the h4a workload. --- Dockerfile | 21 +++++++++ scripts/deploy.sh | 61 ++++++++++++++++++++++++++ src/pluginbattery/static/app.js | 57 ++++++++++++++++++++++-- src/pluginbattery/static/style.css | 15 +++++++ src/pluginbattery/templates/index.html | 10 +++-- 5 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 Dockerfile create mode 100755 scripts/deploy.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1b1347 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PORT=8080 + +WORKDIR /app + +COPY pyproject.toml uv.lock README.md ./ +COPY src ./src + +RUN pip install --no-cache-dir uv \ + && uv pip install --system --no-cache . + +COPY data ./data + +EXPOSE 8080 + +CMD ["sh", "-c", "gunicorn pluginbattery.web:app --bind 0.0.0.0:${PORT:-8080} --workers 1 --timeout 120"] diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..2cc63ee --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Deploy (or redeploy) the web app to the h4a workload. +# +# Prereqs: +# - Workload provisioned via h4a (`thuisbatterij`). +# - SSH alias `thuisbatterij` in ~/.ssh/config pointing at it. +# If h4a rotates the VM, update the HostName in your SSH config and re-run. +# +# Usage: scripts/deploy.sh +set -euo pipefail + +ALIAS="thuisbatterij" +APP_DIR="/opt/thuisbatterij" + +echo "→ checking SSH" +ssh -o BatchMode=yes -o ConnectTimeout=8 ${ALIAS} 'true' + +echo "→ rsyncing source to ${ALIAS}:${APP_DIR}" +ssh ${ALIAS} "mkdir -p ${APP_DIR}" +rsync -az --delete \ + --exclude='.venv' --exclude='__pycache__' --exclude='*.pyc' \ + --exclude='.pytest_cache' --exclude='.ruff_cache' --exclude='.mypy_cache' \ + --exclude='data/processed' --exclude='.DS_Store' --exclude='.env' \ + --exclude='.git' \ + ./ ${ALIAS}:${APP_DIR}/ + +echo "→ installing python deps" +ssh ${ALIAS} "cd ${APP_DIR} && \ + apt-get install -y python3-venv python3.12-venv >/dev/null 2>&1 && \ + ([ -d .venv ] || python3 -m venv .venv) && \ + .venv/bin/pip install -q --upgrade pip && \ + .venv/bin/pip install -q -e ." + +echo "→ installing/updating systemd unit" +ssh ${ALIAS} "cat >/etc/systemd/system/thuisbatterij.service </dev/null || true +systemctl disable h4a-app.service 2>/dev/null || true +systemctl daemon-reload +systemctl enable --now thuisbatterij.service +systemctl restart thuisbatterij.service" + +sleep 3 +echo "→ smoke test" +ssh ${ALIAS} "curl -sf http://127.0.0.1:8080/healthz && echo" + +echo "→ done. If first deploy or DNS just changed, expose + bounce caddy:" +echo " ssh ${ALIAS} 'systemctl restart caddy'" diff --git a/src/pluginbattery/static/app.js b/src/pluginbattery/static/app.js index 643bbd0..99cfb96 100644 --- a/src/pluginbattery/static/app.js +++ b/src/pluginbattery/static/app.js @@ -9,6 +9,13 @@ 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 PLUGIN_POWER_LIMIT_KW = 0.8; +const FILTER_KEY = "pluginbattery_plugin_only"; + +// Latest fetched dataset; the filter toggle re-paints from this. +let lastBatteries = null; function fmtNum(x, decimals = 2) { return x === null || x === undefined ? "—" : Number(x).toFixed(decimals); @@ -96,6 +103,19 @@ function renderTable(rows) { }); } +function applyFilter(batteries) { + if (!pluginOnly.checked) return batteries; + return batteries.filter((b) => b.power_kw <= PLUGIN_POWER_LIMIT_KW + 1e-9); +} + +function repaint() { + if (!lastBatteries) return; + const visible = applyFilter(lastBatteries); + renderBest(visible[0] || null); + renderTable(visible); + countEl.textContent = visible.length; +} + async function recalc() { const scenario = readScenario(); button.disabled = true; @@ -112,10 +132,9 @@ async function recalc() { throw new Error(err.error || `HTTP ${r.status}`); } const data = await r.json(); - renderBest(data.best); - renderTable(data.batteries); - countEl.textContent = data.batteries.length; + lastBatteries = data.batteries; elapsed.textContent = data.elapsed_seconds; + repaint(); statusEl.textContent = `Updated in ${((performance.now() - t0) / 1000).toFixed(1)}s.`; } catch (e) { statusEl.textContent = `Error: ${e.message}`; @@ -124,4 +143,36 @@ async function recalc() { } } +// Capture the SSR-rendered table into lastBatteries so the filter works +// 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"); + return { + 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_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(); +}); + +captureInitialFromDOM(); +if (pluginOnly.checked) repaint(); // honour saved filter on load + form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); }); diff --git a/src/pluginbattery/static/style.css b/src/pluginbattery/static/style.css index e1ee703..897771f 100644 --- a/src/pluginbattery/static/style.css +++ b/src/pluginbattery/static/style.css @@ -125,6 +125,21 @@ 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; } +.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; + color: var(--text); + cursor: pointer; + user-select: none; +} +.filter-toggle input { margin: 0; } + #best-card h3 { margin: 0 0 0.75rem; font-size: 1.5rem; diff --git a/src/pluginbattery/templates/index.html b/src/pluginbattery/templates/index.html index 89c149c..93e0796 100644 --- a/src/pluginbattery/templates/index.html +++ b/src/pluginbattery/templates/index.html @@ -4,7 +4,7 @@ Honest battery payback — vs thuisbatterijgids.nl - + @@ -78,7 +78,11 @@
-

Full leaderboard ({{ initial.batteries|length }} batteries · {{ initial.elapsed_seconds }}s)

+

+ Full leaderboard + ({{ initial.batteries|length }} batteries · {{ initial.elapsed_seconds }}s) + +

@@ -124,6 +128,6 @@

- +