Add plug-in-only filter to web app + deploy script

- 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.
This commit is contained in:
Michiel Berger 2026-04-30 14:58:52 +02:00
parent 60e0706736
commit 1d7574e0b2
5 changed files with 158 additions and 6 deletions

21
Dockerfile Normal file
View file

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

61
scripts/deploy.sh Executable file
View file

@ -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 <<UNIT
[Unit]
Description=Thuisbatterij payback web app
After=network.target
[Service]
Type=simple
WorkingDirectory=${APP_DIR}
ExecStart=${APP_DIR}/.venv/bin/gunicorn pluginbattery.web:app --bind 0.0.0.0:8080 --workers 1 --timeout 180
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
systemctl stop h4a-app.service 2>/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'"

View file

@ -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(); });

View file

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

View file

@ -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=4">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=5">
</head>
<body>
@ -78,7 +78,11 @@
</div>
<div class="card">
<h2>Full leaderboard <small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s)</small></h2>
<h2>
Full leaderboard
<small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s)</small>
<label class="filter-toggle"><input type="checkbox" id="plugin-only"> plug-in only (≤ 0.8 kW, no electrician)</label>
</h2>
<table id="leaderboard">
<thead>
<tr>
@ -124,6 +128,6 @@
</p>
</footer>
<script src="{{ url_for('static', filename='app.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}?v=5"></script>
</body>
</html>