Compare commits

..
6 Commits
22 changed files with 2217 additions and 115 deletions
+30
View File
@@ -0,0 +1,30 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.13"
- name: Install dependencies
run: |
pip install ruff
pip install -r requirements.txt
- name: Lint
run: ruff check .
- name: Run tests
run: pytest tests/ -v
+1 -1
View File
@@ -81,7 +81,7 @@ Battery Tracker gives you a single source of truth:
| Database | SQLite (dev) / MariaDB (prod) | | Database | SQLite (dev) / MariaDB (prod) |
| WSGI server | Waitress | | WSGI server | Waitress |
| Process manager | systemd user service | | Process manager | systemd user service |
| Tests | pytest (145 tests) | | Tests | pytest (255 tests, 99% coverage) |
--- ---
+85 -54
View File
@@ -35,7 +35,7 @@ def _safe_next(default_url):
def _filter_compatible(query, battery_size): def _filter_compatible(query, battery_size):
"""Restrict a Battery query to size-compatible batteries (matching size or unsized).""" """Restrict a Battery query to size-compatible batteries (matching size or unsized)."""
if battery_size: if battery_size:
query = query.filter((Battery.size == battery_size) | (Battery.size == None)) query = query.filter((Battery.size == battery_size) | (Battery.size == None)) # noqa: E711
return query return query
@@ -97,7 +97,7 @@ def create_app(config_object="config"):
poller.start() poller.start()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Dashboard # Home / Battery list
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@app.route("/sw.js") @app.route("/sw.js")
@@ -106,26 +106,14 @@ def create_app(config_object="config"):
mimetype="application/javascript") mimetype="application/javascript")
@app.route("/") @app.route("/")
def dashboard(): def home():
batteries = db.query(Battery).order_by(Battery.label).all() batteries = db.query(Battery).all()
storage_locations = [
r[0] for r in db.query(Battery.storage_location)
.filter(Battery.storage_location.isnot(None))
.distinct().order_by(Battery.storage_location).all()
]
devices = db.query(Device).order_by(Device.name).all()
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots and not d.has_children()]
today = date.today() today = date.today()
one_year_ago = (today - timedelta(days=365)).isoformat() one_year_ago = (today - timedelta(days=365)).isoformat()
total_charges = db.query(func.count(ChargeLog.id)).scalar() or 0 total_charges = db.query(func.count(ChargeLog.id)).scalar() or 0
charges_last_year = (db.query(func.count(ChargeLog.id)) charges_last_year = (db.query(func.count(ChargeLog.id))
.filter(ChargeLog.charged_date >= one_year_ago) .filter(ChargeLog.charged_date >= one_year_ago)
.scalar()) or 0 .scalar()) or 0
last_charged_map = {
r[0]: r[1]
for r in db.query(ChargeLog.battery_id, func.max(ChargeLog.charged_date))
.group_by(ChargeLog.battery_id).all()
}
active = [b for b in batteries if b.status in ("available", "installed")] active = [b for b in batteries if b.status in ("available", "installed")]
needs_attention = { needs_attention = {
"low_capacity": [ "low_capacity": [
@@ -138,14 +126,57 @@ def create_app(config_object="config"):
if b.battery_percentage is not None and b.battery_percentage < 20 if b.battery_percentage is not None and b.battery_percentage < 20
] if ha_client.enabled else [], ] if ha_client.enabled else [],
} }
return render_template("dashboard.html", batteries=batteries, total_batteries = len(batteries)
avail_count = sum(1 for b in batteries if b.status == "available")
installed_count = sum(1 for b in batteries if b.status == "installed")
retired_count = sum(1 for b in batteries if b.status == "retired")
devices = db.query(Device).filter(Device.parent_id == None).all() # noqa: E711
total_devices = len(devices)
full_devices = sum(
1 for d in devices
if d.effective_slots() > 0 and d.effective_installed_count() >= d.effective_slots()
)
partial_devices = sum(
1 for d in devices
if 0 < d.effective_installed_count() < d.effective_slots()
)
empty_devices = sum(1 for d in devices if d.effective_installed_count() == 0)
return render_template("home.html",
total_batteries=total_batteries,
avail_count=avail_count,
installed_count=installed_count,
retired_count=retired_count,
total_devices=total_devices,
full_devices=full_devices,
partial_devices=partial_devices,
empty_devices=empty_devices,
total_charges=total_charges,
charges_last_year=charges_last_year,
needs_attention=needs_attention,
ha_enabled=ha_client.enabled,
today=today)
@app.route("/battery/")
def battery_list():
batteries = db.query(Battery).order_by(Battery.label).all()
storage_locations = [
r[0] for r in db.query(Battery.storage_location)
.filter(Battery.storage_location.isnot(None))
.distinct().order_by(Battery.storage_location).all()
]
devices = db.query(Device).order_by(Device.name).all()
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots and not d.has_children()]
today = date.today()
last_charged_map = {
r[0]: r[1]
for r in db.query(ChargeLog.battery_id, func.max(ChargeLog.charged_date))
.group_by(ChargeLog.battery_id).all()
}
return render_template("battery_list.html", batteries=batteries,
storage_locations=storage_locations, devices=devices, storage_locations=storage_locations, devices=devices,
devices_with_slots=devices_with_slots, devices_with_slots=devices_with_slots,
ha_enabled=ha_client.enabled, ha_enabled=ha_client.enabled,
total_charges=total_charges,
charges_last_year=charges_last_year,
last_charged_map=last_charged_map, last_charged_map=last_charged_map,
needs_attention=needs_attention,
today=today) today=today)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -201,7 +232,7 @@ def create_app(config_object="config"):
purchase_date=purchase_date, storage_location=storage_location)) purchase_date=purchase_date, storage_location=storage_location))
db.commit() db.commit()
flash(f"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success") flash(f"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()] brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
storage_locations = [ storage_locations = [
@@ -252,16 +283,16 @@ def create_app(config_object="config"):
.order_by(BatteryPctLog.recorded_at.desc()) .order_by(BatteryPctLog.recorded_at.desc())
.all()) .all())
charge_logs_data = [ charge_logs_data = [
{"id": l.id, "date": l.charged_date, "cycles": l.increment_cycles, "notes": l.notes or ""} {"id": log.id, "date": log.charged_date, "cycles": log.increment_cycles, "notes": log.notes or ""}
for l in charge_logs for log in charge_logs
] ]
capacity_tests_data = [ capacity_tests_data = [
{"id": t.id, "date": t.tested_date, "mah": t.tested_capacity_mah, "notes": t.notes or ""} {"id": t.id, "date": t.tested_date, "mah": t.tested_capacity_mah, "notes": t.notes or ""}
for t in sorted(capacity_tests, key=lambda t: (t.tested_date, t.id), reverse=True) for t in sorted(capacity_tests, key=lambda t: (t.tested_date, t.id), reverse=True)
] ]
pct_logs_data = [ pct_logs_data = [
{"recorded_at": str(l.recorded_at), "pct": l.percentage, "source": l.source or ""} {"recorded_at": str(log.recorded_at), "pct": log.percentage, "source": log.source or ""}
for l in pct_logs for log in pct_logs
] ]
return render_template("battery_detail.html", battery=battery, return render_template("battery_detail.html", battery=battery,
storage_locations=storage_locations, storage_locations=storage_locations,
@@ -454,7 +485,7 @@ def create_app(config_object="config"):
battery.device_id = device.id battery.device_id = device.id
db.commit() db.commit()
flash(f"{battery.label} assigned to {device.name}.", "success") flash(f"{battery.label} assigned to {device.name}.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
return render_template("assign.html", battery=battery, devices=devices_with_slots) return render_template("assign.html", battery=battery, devices=devices_with_slots)
@@ -471,7 +502,7 @@ def create_app(config_object="config"):
battery.device_id = None battery.device_id = None
db.commit() db.commit()
flash(f"{battery.label} unassigned and marked available.", "success") flash(f"{battery.label} unassigned and marked available.", "success")
return redirect(_safe_next(url_for("dashboard"))) return redirect(_safe_next(url_for("battery_list")))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — retire # Battery — retire
@@ -489,7 +520,7 @@ def create_app(config_object="config"):
battery.device_id = None battery.device_id = None
db.commit() db.commit()
flash(f"{battery.label} has been retired.", "success") flash(f"{battery.label} has been retired.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — unretire # Battery — unretire
@@ -522,7 +553,7 @@ def create_app(config_object="config"):
db.delete(battery) db.delete(battery)
db.commit() db.commit()
flash(f"Battery {label} permanently deleted.", "success") flash(f"Battery {label} permanently deleted.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
return render_template("battery_delete.html", battery=battery) return render_template("battery_delete.html", battery=battery)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -534,7 +565,7 @@ def create_app(config_object="config"):
ids = request.form.getlist("battery_ids", type=int) ids = request.form.getlist("battery_ids", type=int)
if not ids: if not ids:
flash("No batteries selected.", "error") flash("No batteries selected.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
batteries = db.query(Battery).filter(Battery.id.in_(ids)).all() batteries = db.query(Battery).filter(Battery.id.in_(ids)).all()
action = request.form.get("action") action = request.form.get("action")
@@ -563,7 +594,7 @@ def create_app(config_object="config"):
new_brand = request.form.get("new_brand", "").strip() new_brand = request.form.get("new_brand", "").strip()
if not new_brand: if not new_brand:
flash("Brand name is required.", "error") flash("Brand name is required.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
for b in batteries: for b in batteries:
b.brand = new_brand b.brand = new_brand
db.commit() db.commit()
@@ -572,15 +603,15 @@ def create_app(config_object="config"):
device_id = request.form.get("device_id", type=int) device_id = request.form.get("device_id", type=int)
if not device_id: if not device_id:
flash("Please select a device.", "error") flash("Please select a device.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
device = db.get(Device, device_id) device = db.get(Device, device_id)
if device is None: if device is None:
flash("Device not found.", "error") flash("Device not found.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
if device.has_children(): if device.has_children():
flash(f"{device.name} has sub-components; install batteries into those instead.", "error") flash(f"{device.name} has sub-components; install batteries into those instead.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
already_here = [b for b in batteries if b.device_id == device.id] already_here = [b for b in batteries if b.device_id == device.id]
retired_sel = [b for b in batteries if b.is_retired()] retired_sel = [b for b in batteries if b.is_retired()]
@@ -589,7 +620,7 @@ def create_app(config_object="config"):
if not to_process: if not to_process:
flash("No eligible batteries to install.", "error") flash("No eligible batteries to install.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
free_slots = device.battery_slots - device.installed_count() free_slots = device.battery_slots - device.installed_count()
if len(to_process) > free_slots: if len(to_process) > free_slots:
@@ -598,7 +629,7 @@ def create_app(config_object="config"):
f"but {len(to_process)} need installing.", f"but {len(to_process)} need installing.",
"error", "error",
) )
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
existing_brands = device.installed_brands() existing_brands = device.installed_brands()
new_brands = set(b.brand for b in to_process) new_brands = set(b.brand for b in to_process)
@@ -627,10 +658,10 @@ def create_app(config_object="config"):
allowed = {"brand", "storage_location"} allowed = {"brand", "storage_location"}
if field_name not in allowed: if field_name not in allowed:
flash("Invalid field.", "error") flash("Invalid field.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
if field_name == "brand" and not field_value: if field_name == "brand" and not field_value:
flash("Brand name is required.", "error") flash("Brand name is required.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
for b in batteries: for b in batteries:
setattr(b, field_name, field_value) setattr(b, field_name, field_value)
db.commit() db.commit()
@@ -640,7 +671,7 @@ def create_app(config_object="config"):
date_val = _parse_date(request.form.get("charged_date", "").strip()) date_val = _parse_date(request.form.get("charged_date", "").strip())
if not date_val: if not date_val:
flash("A valid date (YYYY-MM-DD) is required.", "error") flash("A valid date (YYYY-MM-DD) is required.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
increment = 1 if request.form.get("increment_cycles") else 0 increment = 1 if request.form.get("increment_cycles") else 0
for b in batteries: for b in batteries:
_record_charge(db, b, date_val, increment, notes=None) _record_charge(db, b, date_val, increment, notes=None)
@@ -654,7 +685,7 @@ def create_app(config_object="config"):
else: else:
flash("Unknown action.", "error") flash("Unknown action.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — list # Devices — list
@@ -668,7 +699,7 @@ def create_app(config_object="config"):
@app.route("/device/") @app.route("/device/")
def device_list(): def device_list():
devices = db.query(Device).filter(Device.parent_id == None).order_by(Device.name).all() devices = db.query(Device).filter(Device.parent_id == None).order_by(Device.name).all() # noqa: E711
device_types = sorted({d.device_type for d in devices if d.device_type}) device_types = sorted({d.device_type for d in devices if d.device_type})
device_locations = sorted({d.location for d in devices if d.location}) device_locations = sorted({d.location for d in devices if d.location})
device_battery_sizes = sorted({d.battery_size for d in devices if d.battery_size}) device_battery_sizes = sorted({d.battery_size for d in devices if d.battery_size})
@@ -1206,9 +1237,9 @@ def create_app(config_object="config"):
buf = io.StringIO() buf = io.StringIO()
w = csv.writer(buf) w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "charged_date", "increment_cycles", "notes"]) w.writerow(["id", "battery_id", "battery_label", "charged_date", "increment_cycles", "notes"])
for l in rows: for row in rows:
w.writerow([l.id, l.battery_id, l.battery.label, w.writerow([row.id, row.battery_id, row.battery.label,
l.charged_date, l.increment_cycles, l.notes or ""]) row.charged_date, row.increment_cycles, row.notes or ""])
return buf.getvalue() return buf.getvalue()
def _capacity_tests_csv(): def _capacity_tests_csv():
@@ -1226,9 +1257,9 @@ def create_app(config_object="config"):
buf = io.StringIO() buf = io.StringIO()
w = csv.writer(buf) w = csv.writer(buf)
w.writerow(["id", "battery_id", "battery_label", "percentage", "recorded_at", "source"]) w.writerow(["id", "battery_id", "battery_label", "percentage", "recorded_at", "source"])
for l in rows: for row in rows:
w.writerow([l.id, l.battery_id, l.battery.label, w.writerow([row.id, row.battery_id, row.battery.label,
l.percentage, l.recorded_at, l.source or ""]) row.percentage, row.recorded_at, row.source or ""])
return buf.getvalue() return buf.getvalue()
@app.route("/export") @app.route("/export")
@@ -1303,10 +1334,10 @@ def create_app(config_object="config"):
for d in devices for d in devices
], ],
"charge_logs": [ "charge_logs": [
{"id": l.id, "battery_id": l.battery_id, "battery_label": l.battery.label, {"id": log.id, "battery_id": log.battery_id, "battery_label": log.battery.label,
"charged_date": l.charged_date, "increment_cycles": l.increment_cycles, "charged_date": log.charged_date, "increment_cycles": log.increment_cycles,
"notes": l.notes} "notes": log.notes}
for l in charge_logs for log in charge_logs
], ],
"capacity_tests": [ "capacity_tests": [
{"id": t.id, "battery_id": t.battery_id, "battery_label": t.battery.label, {"id": t.id, "battery_id": t.battery_id, "battery_label": t.battery.label,
@@ -1315,9 +1346,9 @@ def create_app(config_object="config"):
for t in capacity_tests for t in capacity_tests
], ],
"pct_logs": [ "pct_logs": [
{"id": l.id, "battery_id": l.battery_id, "battery_label": l.battery.label, {"id": log.id, "battery_id": log.battery_id, "battery_label": log.battery.label,
"percentage": l.percentage, "recorded_at": l.recorded_at, "source": l.source} "percentage": log.percentage, "recorded_at": log.recorded_at, "source": log.source}
for l in pct_logs for log in pct_logs
], ],
} }
return Response(json.dumps(payload, indent=2), mimetype="application/json", return Response(json.dumps(payload, indent=2), mimetype="application/json",
+1 -1
View File
@@ -37,7 +37,7 @@ class HaPoller:
self._poll_once() self._poll_once()
def _poll_once(self): def _poll_once(self):
from models import Battery, BatteryPctLog, Device # local import avoids circular-import risk from models import BatteryPctLog, Device # local import avoids circular-import risk
session = self._Session() session = self._Session()
try: try:
+3 -1
View File
@@ -1,4 +1,3 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table, UniqueConstraint from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table, UniqueConstraint
from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm import declarative_base, relationship
@@ -76,6 +75,9 @@ class Device(Base):
def is_subcomponent(self): def is_subcomponent(self):
return self.parent_id is not None return self.parent_id is not None
def can_have_subcomponents(self):
return self.parent_id is None and self.battery_slots == 0
def effective_installed_count(self): def effective_installed_count(self):
if self.has_children(): if self.has_children():
return sum(c.installed_count() for c in self.children) return sum(c.installed_count() for c in self.children)
+3 -1
View File
@@ -1,5 +1,7 @@
"""Generate solid-color PNG icons for PWA manifest using stdlib only (no Pillow).""" """Generate solid-color PNG icons for PWA manifest using stdlib only (no Pillow)."""
import zlib, struct, os import zlib
import struct
import os
def make_png(size, rgb=(0x25, 0x63, 0xEB)): def make_png(size, rgb=(0x25, 0x63, 0xEB)):
"""Create a minimal valid RGB PNG of the given size filled with one color.""" """Create a minimal valid RGB PNG of the given size filled with one color."""
-1
View File
@@ -16,7 +16,6 @@ Usage:
import os import os
import shutil import shutil
import sys
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
+5 -5
View File
@@ -31,11 +31,11 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT))
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text # noqa: E402
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker # noqa: E402
import config import config # noqa: E402
from models import Base, Battery, BatteryPctLog, CapacityTest, ChargeLog, Device from models import Base, Battery, BatteryPctLog, CapacityTest, ChargeLog, Device # noqa: E402
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -62,7 +62,7 @@ def collect_credentials() -> str:
"""Return a MariaDB SQLAlchemy URL, prompting for any missing pieces.""" """Return a MariaDB SQLAlchemy URL, prompting for any missing pieces."""
url = os.environ.get("MARIADB_URL", "").strip() url = os.environ.get("MARIADB_URL", "").strip()
if url: if url:
print(f" Using MARIADB_URL from environment.") print(" Using MARIADB_URL from environment.")
return url return url
print("Enter MariaDB connection details (press Enter to accept defaults):\n") print("Enter MariaDB connection details (press Enter to accept defaults):\n")
+21
View File
@@ -61,6 +61,27 @@ function metaSelectChanged(sel, inputId) {
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal(); if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
}); });
// Apply URL params as initial filter state on battery_list and device_list
document.addEventListener('DOMContentLoaded', function() {
var params = new URLSearchParams(window.location.search);
var statusParam = params.get('status');
if (statusParam !== null) {
var statusSel = document.getElementById('filter-status');
if (statusSel && typeof applyFilters === 'function') {
statusSel.value = statusParam;
applyFilters();
}
}
var fillParam = params.get('fill');
if (fillParam !== null) {
var fillSel = document.getElementById('filter-fill');
if (fillSel && typeof applyDeviceFilters === 'function') {
fillSel.value = fillParam;
applyDeviceFilters();
}
}
});
// Global handler: forms with data-confirm attribute // Global handler: forms with data-confirm attribute
document.addEventListener('submit', function(e) { document.addEventListener('submit', function(e) {
var form = e.target; var form = e.target;
+2 -1
View File
@@ -2,9 +2,10 @@
"name": "Battery Tracker", "name": "Battery Tracker",
"short_name": "Batteries", "short_name": "Batteries",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#2563eb", "theme_color": "#1e40af",
"icons": [ "icons": [
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" } { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
+56
View File
@@ -116,6 +116,7 @@
nav .brand:hover { text-decoration: none; opacity: 0.9; } nav .brand:hover { text-decoration: none; opacity: 0.9; }
nav a { color: #bfdbfe; font-size: 0.9rem; padding: 0.25rem 0.5rem; border-radius: 4px; white-space: nowrap; } nav a { color: #bfdbfe; font-size: 0.9rem; padding: 0.25rem 0.5rem; border-radius: 4px; white-space: nowrap; }
nav a:hover { background: #1d4ed8; color: #fff; text-decoration: none; } nav a:hover { background: #1d4ed8; color: #fff; text-decoration: none; }
nav a.active { background: #1d4ed8; color: #fff; }
/* Layout */ /* Layout */
.container { max-width: 960px; margin: 1.5rem auto; padding: 0 1rem; } .container { max-width: 960px; margin: 1.5rem auto; padding: 0 1rem; }
@@ -144,6 +145,11 @@
.badge-retired { background: var(--badge-retired-bg); color: var(--badge-retired-text); } .badge-retired { background: var(--badge-retired-bg); color: var(--badge-retired-text); }
.badge-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); } .badge-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); }
/* Clickable stat cards on home page */
.stat-link { display: block; flex: 1; min-width: 120px; text-decoration: none; }
.stat-link:hover { text-decoration: none; }
.stat-link:hover .card { box-shadow: 0 2px 8px rgba(0,0,0,.18); }
/* Buttons */ /* Buttons */
.btn { display: inline-block; padding: 0.4rem 0.9rem; border-radius: 4px; border: none; cursor: pointer; font-size: 0.875rem; font-family: inherit; text-decoration: none; } .btn { display: inline-block; padding: 0.4rem 0.9rem; border-radius: 4px; border: none; cursor: pointer; font-size: 0.875rem; font-family: inherit; text-decoration: none; }
.btn:hover { text-decoration: none; } .btn:hover { text-decoration: none; }
@@ -191,6 +197,7 @@
nav { background: #0c1a3b; } nav { background: #0c1a3b; }
nav a { color: #93c5fd; } nav a { color: #93c5fd; }
nav a:hover { background: #1e3a8a; color: #fff; } nav a:hover { background: #1e3a8a; color: #fff; }
nav a.active { background: #1e3a8a; color: #fff; }
nav .brand { color: #e2e8f0; } nav .brand { color: #e2e8f0; }
#bulk-toolbar { background: var(--bg-toolbar) !important; } #bulk-toolbar { background: var(--bg-toolbar) !important; }
@@ -300,4 +307,53 @@
.form-actions { flex-direction: column; align-items: stretch; } .form-actions { flex-direction: column; align-items: stretch; }
.form-actions .btn, .form-actions .btn,
.form-actions button { width: 100%; text-align: center; } .form-actions button { width: 100%; text-align: center; }
/* ── Mobile: html bg matches nav so status bar area has no color gap ── */
html { background: #1e40af; }
/* ── Mobile: title-bar nav fills behind status bar, links hidden ── */
nav:not(.bottom-nav) {
padding-top: calc(0.4rem + env(safe-area-inset-top));
padding-bottom: 0.4rem;
justify-content: center;
}
nav:not(.bottom-nav) a:not(.brand) { display: none; }
nav:not(.bottom-nav) .brand { margin-right: 0; font-size: 0.95rem; }
body { padding-bottom: 4.5rem; }
.bottom-nav {
position: fixed;
bottom: 0; left: 0; right: 0;
display: flex;
background: #1e40af;
border-top: 1px solid #1d4ed8;
z-index: 100;
padding-bottom: env(safe-area-inset-bottom);
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.5rem 0.25rem 0.4rem;
color: #bfdbfe;
text-decoration: none;
font-size: 0.65rem;
gap: 0.2rem;
-webkit-tap-highlight-color: transparent;
}
.bottom-nav-item.active { color: #fff; }
.bottom-nav-item svg { display: block; }
}
/* ── Desktop: hide bottom nav ── */
@media (min-width: 641px) {
.bottom-nav { display: none; }
}
/* ── Dark mode: bottom nav + html background ── */
@media (prefers-color-scheme: dark) and (max-width: 640px) {
html { background: #0c1a3b; }
.bottom-nav { background: #0c1a3b; border-top-color: #1e3a8a; }
} }
+29 -19
View File
@@ -5,9 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Battery Tracker{% endblock %}</title> <title>{% block title %}Battery Tracker{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json"> <link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#2563eb"> <meta name="theme-color" content="#1e40af" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0c1a3b" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Batteries"> <meta name="apple-mobile-web-app-title" content="Batteries">
<link rel="apple-touch-icon" href="/static/icon-192.png"> <link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"> <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
@@ -16,10 +17,10 @@
</head> </head>
<body> <body>
<nav> <nav>
<a class="brand" href="{{ url_for('dashboard') }}">Battery Tracker</a> {% set ep = request.endpoint or '' %}
<a href="{{ url_for('device_list') }}">Devices</a> <a class="brand" href="{{ url_for('home') }}">Battery Tracker</a>
<a href="{{ url_for('battery_add') }}">+ Battery</a> <a href="{{ url_for('battery_list') }}"{% if ep.startswith('battery') %} class="active"{% endif %}>Batteries</a>
<a href="{{ url_for('device_add') }}">+ Device</a> <a href="{{ url_for('device_list') }}"{% if ep.startswith('device') %} class="active"{% endif %}>Devices</a>
</nav> </nav>
<div class="container"> <div class="container">
@@ -32,19 +33,6 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<footer style="text-align:center;padding:1.25rem 1rem 1.5rem;margin-top:1rem;
border-top:1px solid var(--border);font-size:0.8rem;">
<a href="{{ url_for('export_page') }}"
style="color:var(--text-muted);text-decoration:none;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">Export data</a>
<span style="color:var(--text-muted);margin:0 0.5rem;">·</span>
<a href="{{ url_for('import_page') }}"
style="color:var(--text-muted);text-decoration:none;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">Import data</a>
</footer>
<div id="confirm-modal" role="dialog" aria-modal="true"> <div id="confirm-modal" role="dialog" aria-modal="true">
<div id="confirm-modal-box"> <div id="confirm-modal-box">
<p id="confirm-modal-msg"></p> <p id="confirm-modal-msg"></p>
@@ -56,5 +44,27 @@
</div> </div>
<script src="{{ url_for('static', filename='app.js') }}" defer></script> <script src="{{ url_for('static', filename='app.js') }}" defer></script>
<nav class="bottom-nav" aria-label="Main navigation">
{% set ep = request.endpoint or '' %}
<a href="{{ url_for('home') }}" class="bottom-nav-item{% if ep == 'home' %} active{% endif %}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<span>Home</span>
</a>
<a href="{{ url_for('battery_list') }}" class="bottom-nav-item{% if ep.startswith('battery') %} active{% endif %}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="7" width="16" height="10" rx="2"/><path d="M22 11v2"/>
</svg>
<span>Batteries</span>
</a>
<a href="{{ url_for('device_list') }}" class="bottom-nav-item{% if ep.startswith('device') %} active{% endif %}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
<span>Devices</span>
</a>
</nav>
</body> </body>
</html> </html>
+14 -14
View File
@@ -23,6 +23,19 @@
style="display:none;margin-top:0.4rem;"> style="display:none;margin-top:0.4rem;">
</div> </div>
<div class="form-group">
<label>Size</label>
<select id="size-select" onchange="metaSelectChanged(this,'size')">
<option value="">— none —</option>
{% for opt in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}
<option value="{{ opt }}">{{ opt }}</option>
{% endfor %}
<option value="__new__">Other…</option>
</select>
<input type="text" id="size" name="size" value=""
placeholder="Enter size" style="display:none;margin-top:0.4rem;">
</div>
<div class="form-group"> <div class="form-group">
<label for="count">Quantity</label> <label for="count">Quantity</label>
<input type="number" id="count" name="count" value="{{ form_count|default(1) }}" min="1" max="50"> <input type="number" id="count" name="count" value="{{ form_count|default(1) }}" min="1" max="50">
@@ -42,19 +55,6 @@
value="" placeholder="e.g. 2000 — optional"> value="" placeholder="e.g. 2000 — optional">
</div> </div>
<div class="form-group">
<label>Size</label>
<select id="size-select" onchange="metaSelectChanged(this,'size')">
<option value="">— none —</option>
{% for opt in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}
<option value="{{ opt }}">{{ opt }}</option>
{% endfor %}
<option value="__new__">Other…</option>
</select>
<input type="text" id="size" name="size" value=""
placeholder="Enter size" style="display:none;margin-top:0.4rem;">
</div>
<div class="form-group"> <div class="form-group">
<label>Chemistry</label> <label>Chemistry</label>
<select id="chemistry-select" onchange="metaSelectChanged(this,'chemistry')"> <select id="chemistry-select" onchange="metaSelectChanged(this,'chemistry')">
@@ -97,7 +97,7 @@
<div class="form-actions"> <div class="form-actions">
<button class="btn btn-primary" type="submit">Add Batteries</button> <button class="btn btn-primary" type="submit">Add Batteries</button>
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Cancel</a> <a class="btn btn-secondary" href="{{ url_for('battery_list') }}">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
+1 -1
View File
@@ -362,7 +362,7 @@
</div> </div>
</div> </div>
<a class="text-muted" href="{{ url_for('dashboard') }}">&larr; Back to Dashboard</a> <a class="text-muted" href="{{ url_for('battery_list') }}">&larr; Back to Batteries</a>
<script> <script>
(function() { (function() {
+561
View File
@@ -0,0 +1,561 @@
{% extends "base.html" %}
{% block title %}Batteries — Battery Tracker{% endblock %}
{% block content %}
<h1>Batteries</h1>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<a class="btn btn-primary btn-sm" href="{{ url_for('battery_add') }}">+ Add Battery</a>
<div style="position:relative;">
<button type="button" id="col-picker-btn" class="btn btn-sm btn-secondary">Columns ▾</button>
<div id="col-picker-panel" style="display:none;position:absolute;right:0;top:calc(100% + 4px);background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:0.75rem 1rem;z-index:100;box-shadow:0 4px 8px rgba(0,0,0,0.1);min-width:180px;">
<div style="font-weight:600;font-size:0.8rem;color:#64748b;margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em;">Show columns</div>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="last-charged" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Last Charged</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="health" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Health %</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="chemistry" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Chemistry</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="capacity" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Capacity</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="storage" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Storage Location</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="purchase" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Purchase Date</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="cycles" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Charge Cycles</label>
{% if ha_enabled %}<label style="display:block;cursor:pointer;font-size:0.875rem;"><input type="checkbox" data-col="ha-pct" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Battery %</label>{% endif %}
</div>
</div>
</div>
<div id="filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;">
<select id="filter-status" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="active" selected>Active (non-retired)</option>
<option value="">All Statuses</option>
<option value="available">Available</option>
<option value="installed">Installed</option>
<option value="retired">Retired</option>
</select>
<select id="filter-brand" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">All Brands</option>
{% for b in batteries|map(attribute='brand')|unique|sort %}
<option value="{{ b }}">{{ b }}</option>
{% endfor %}
</select>
<select id="filter-size" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">All Sizes</option>
{% for s in batteries|map(attribute='size')|select|unique|sort %}
<option value="{{ s }}">{{ s }}</option>
{% endfor %}
</select>
<select id="filter-storage" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">All Locations</option>
{% for loc in storage_locations %}
<option value="{{ loc }}">{{ loc }}</option>
{% endfor %}
</select>
<input type="text" id="filter-text" oninput="applyFilters()" placeholder="Search…"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;">
<input type="number" id="filter-range-from" oninput="applyFilters()" placeholder="# from"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:70px;">
<input type="number" id="filter-range-to" oninput="applyFilters()" placeholder="# to"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:70px;">
<button type="button" id="select-all-btn" onclick="mobileSelectAll()" class="btn btn-sm btn-secondary" style="display:none;">Select all</button>
<button type="button" onclick="resetFilters()" class="btn btn-sm btn-secondary" id="filter-reset" style="display:none;">✕ Reset</button>
<span id="filter-count" style="font-size:0.8rem;color:#64748b;"></span>
</div>
<form method="post" action="{{ url_for('battery_bulk_action') }}" id="bulk-form">
<div id="bulk-toolbar" style="display:none;margin-bottom:0.75rem;padding:0.6rem 0.75rem;background:#f1f5f9;border-radius:6px;align-items:center;gap:0.5rem;flex-wrap:wrap;position:sticky;top:0;z-index:90;box-shadow:0 2px 6px rgba(0,0,0,.08);">
<span id="selected-count" style="font-size:0.85rem;color:#64748b;margin-right:0.25rem;"></span>
<button class="btn btn-sm btn-warning" name="action" value="unassign" type="submit">Unassign</button>
<button class="btn btn-sm btn-secondary" name="action" value="retire" type="submit">Retire</button>
<button class="btn btn-sm btn-danger" name="action" value="delete" type="button"
onclick="bulkActionConfirm(this, 'Permanently delete selected batteries?', 'Delete', 'btn-danger')">Delete</button>
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
<input type="hidden" name="field_name" id="bulk-field-name" value="storage_location">
<select id="bulk-field-select" onchange="updateBulkField(this)"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="storage_location">Storage Location</option>
<option value="brand">Brand</option>
</select>
<!-- Storage Location value -->
<span id="bulk-val-storage_location" style="display:flex;gap:0.25rem;align-items:center;">
<select id="bulk-storage-select" onchange="bulkStorageChanged(this)"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">— select —</option>
{% for loc in storage_locations|default([]) %}
<option value="{{ loc }}">{{ loc }}</option>
{% endfor %}
<option value="__new__"> New location…</option>
</select>
<input type="text" id="bulk-storage-text"
style="display:none;padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;"
placeholder="Type location">
<input type="hidden" name="field_value" id="bulk-field-value-storage" value="">
</span>
<!-- Brand value -->
<span id="bulk-val-brand" style="display:none;">
<input type="text" id="bulk-brand-text" oninput="document.getElementById('bulk-field-value-brand').value=this.value"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:160px;"
placeholder="New brand name">
<input type="hidden" name="field_value" id="bulk-field-value-brand" value="">
</span>
<button class="btn btn-sm btn-primary" name="action" value="set_field" type="submit">Apply</button>
</span>
<span style="display:flex;gap:0.35rem;align-items:center;">
<select id="bulk-device-select" name="device_id"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">— select device —</option>
{% for d in devices_with_slots %}
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %}
</select>
<button class="btn btn-sm btn-primary" name="action" value="install_device" type="button"
onclick="confirmInstallDevice(this)">Install in device</button>
</span>
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
<input type="date" name="charged_date" id="bulk-charged-date" value="{{ today.isoformat() }}"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<label style="font-size:0.85rem;display:flex;align-items:center;gap:0.25rem;cursor:pointer;">
<input type="checkbox" name="increment_cycles" id="bulk-increment-cycles" value="1" checked>
+cycle
</label>
<button class="btn btn-sm btn-primary" name="action" value="log_charged" type="submit"
onclick="return validateBulkCharge()">Log Charged</button>
</span>
</div>
<div class="table-wrap">
<table class="responsive-table">
<thead>
<tr>
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
<th data-sortable="label">Label</th>
<th data-sortable="brand">Brand</th>
<th>Size</th>
<th class="col-last-charged" style="display:none;" data-sortable="last-charged">Last Charged</th>
<th class="col-health" style="display:none;" data-sortable="health">Health</th>
<th class="col-chemistry" style="display:none;">Chemistry</th>
<th class="col-capacity" style="display:none;">Capacity</th>
<th class="col-storage" style="display:none;">Storage</th>
<th class="col-purchase" style="display:none;">Purchase Date</th>
<th class="col-cycles" style="display:none;" data-sortable="cycles">Cycles</th>
{% if ha_enabled %}<th class="col-ha-pct" style="display:none;" data-sortable="ha-pct">Bat %</th>{% endif %}
<th data-sortable="status">Status</th>
<th>Assigned To</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for b in batteries %}
<tr data-brand="{{ b.brand }}" data-size="{{ b.size or '' }}" data-status="{{ b.status }}" data-storage="{{ b.storage_location or '' }}" data-label="{{ b.label }}">
<td data-label=""><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
<td data-label="Label" data-sort-col="label" data-sort="{{ b.label }}"><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
<td data-label="Brand" data-sort-col="brand" data-sort="{{ b.brand }}">{{ b.brand }}</td>
<td data-label="Size">{{ b.size or '—' }}</td>
<td data-label="Last Charged" class="col-last-charged" style="display:none;"
data-sort-col="last-charged" data-sort="{{ last_charged_map.get(b.id, '') }}"
data-charged="{{ last_charged_map.get(b.id, '') }}"></td>
<td data-label="Health" class="col-health" style="display:none;"
data-sort-col="health" data-sort="{% if b.tested_capacity_mah and b.capacity_mah %}{{ (b.tested_capacity_mah / b.capacity_mah * 100)|int }}{% endif %}">
{% if b.tested_capacity_mah and b.capacity_mah %}
{% set hp = (b.tested_capacity_mah / b.capacity_mah * 100)|int %}
<span class="badge {% if hp >= 80 %}badge-available{% elif hp >= 60 %}badge-warning{% else %}badge-retired{% endif %}">{{ hp }}%</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td data-label="Chemistry" class="col-chemistry" style="display:none;">{{ b.chemistry or '—' }}</td>
<td data-label="Capacity" class="col-capacity" style="display:none;">
{% if b.capacity_mah %}
{% if b.tested_capacity_mah %}{{ b.tested_capacity_mah }}/{{ b.capacity_mah }} mAh
{% else %}{{ b.capacity_mah }} mAh{% endif %}
{% else %}—{% endif %}
</td>
<td data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
<td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
<td data-label="Cycles" class="col-cycles" style="display:none;"
data-sort-col="cycles" data-sort="{{ b.charge_cycles or '' }}">{{ b.charge_cycles or '—' }}</td>
{% if ha_enabled %}
<td data-label="Bat %" class="col-ha-pct" style="display:none;"
data-sort-col="ha-pct" data-sort="{{ b.battery_percentage if b.battery_percentage is not none else '' }}">
{% if b.battery_percentage is not none %}
{% if b.battery_percentage < 20 and b.status != 'retired' %}
<span class="badge badge-warning" title="Low — consider replacing">⚠ {{ b.battery_percentage }}%</span>
{% else %}{{ b.battery_percentage }}%{% endif %}
{% else %}—{% endif %}
</td>
{% endif %}
<td data-label="Status" data-sort-col="status" data-sort="{{ b.status }}">
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
</td>
<td data-label="Assigned To">
{% if b.device %}
{% if b.device.parent %}
<a href="{{ url_for('device_detail', device_id=b.device.parent.id) }}">{{ b.device.parent.name }}</a>
<span class="text-muted"> / </span>
{% endif %}
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
{% if b.device.has_mixed_brands() %}
<span class="badge badge-warning" title="Mixed brands in this device">⚠ mixed</span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td data-label="Actions" style="white-space:nowrap;">
<a class="btn btn-sm btn-secondary" href="{{ url_for('battery_detail', battery_id=b.id) }}">View</a>
{% if b.is_available() %}
<select id="qas-{{ b.id }}"
style="padding:0.2rem 0.3rem;font-size:0.8rem;border:1px solid #cbd5e1;border-radius:4px;max-width:110px;vertical-align:middle;">
<option value="">— assign —</option>
{% for d in devices_with_slots %}
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %}
</select>
<button type="button" class="btn btn-sm btn-primary"
onclick="quickAssign('{{ url_for('battery_assign', battery_id=b.id) }}', {{ b.id }})"></button>
{% endif %}
{% if b.is_installed() %}
<button class="btn btn-sm btn-warning" type="submit"
formaction="{{ url_for('battery_unassign', battery_id=b.id) }}">Unassign</button>
{% endif %}
{% if not b.is_retired() %}
<button class="btn btn-sm btn-secondary" type="submit"
formaction="{{ url_for('battery_retire', battery_id=b.id) }}">Retire</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="12" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add some.</a></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="pagination-bar" style="display:flex;align-items:center;gap:0.75rem;margin-top:0.75rem;flex-wrap:wrap;">
<button id="prev-page" class="btn btn-sm btn-secondary" type="button"
onclick="currentPage--; applyPagination();" disabled>&#8592; Prev</button>
<span id="page-info" style="color:var(--text-muted);font-size:0.9rem;"></span>
<button id="next-page" class="btn btn-sm btn-secondary" type="button"
onclick="currentPage++; applyPagination();" disabled>Next &#8594;</button>
<select id="page-size-select" onchange="PAGE_SIZE=+this.value||Infinity;currentPage=1;applyPagination();"
style="padding:0.2rem 0.4rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="10">10 / page</option>
<option value="25" selected>25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
<option value="">All</option>
</select>
</div>
</form>
</div>
<script>
var CSRF_TOKEN = '{{ csrf_token() }}';
var selectAll = document.getElementById('select-all');
var toolbar = document.getElementById('bulk-toolbar');
var countEl = document.getElementById('selected-count');
var selectAllBtn = document.getElementById('select-all-btn');
var PAGE_SIZE = 25;
var currentPage = 1;
function visibleCbs() {
return Array.prototype.filter.call(
document.querySelectorAll('.row-cb'),
function(cb) { return cb.closest('tr').style.display !== 'none'; }
);
}
function updateToolbar() {
var checked = document.querySelectorAll('.row-cb:checked');
var vis = visibleCbs();
var n = checked.length;
toolbar.style.display = n > 0 ? 'flex' : 'none';
countEl.textContent = n + ' selected';
var visChecked = vis.filter(function(cb) { return cb.checked; });
selectAll.indeterminate = visChecked.length > 0 && visChecked.length < vis.length;
selectAll.checked = vis.length > 0 && visChecked.length === vis.length;
if (selectAllBtn) {
selectAllBtn.style.display = vis.length > 0 ? '' : 'none';
selectAllBtn.textContent = (vis.length > 0 && visChecked.length === vis.length)
? 'Deselect all' : 'Select all';
}
}
function mobileSelectAll() {
var vis = visibleCbs();
var allChecked = vis.length > 0 && vis.every(function(cb) { return cb.checked; });
vis.forEach(function(cb) { cb.checked = !allChecked; });
updateToolbar();
}
document.querySelectorAll('.row-cb').forEach(function(cb) {
cb.addEventListener('change', updateToolbar);
});
selectAll.addEventListener('change', function() {
visibleCbs().forEach(function(cb) { cb.checked = selectAll.checked; });
updateToolbar();
});
function applyFilters() {
var status = document.getElementById('filter-status').value;
var brand = document.getElementById('filter-brand').value;
var size = document.getElementById('filter-size').value;
var storage = document.getElementById('filter-storage').value;
var text = document.getElementById('filter-text').value.trim().toLowerCase();
var rangeFromVal = document.getElementById('filter-range-from').value.trim();
var rangeToVal = document.getElementById('filter-range-to').value.trim();
var rangeMin = rangeFromVal !== '' ? parseInt(rangeFromVal, 10) : null;
var rangeMax = rangeToVal !== '' ? parseInt(rangeToVal, 10) : null;
var anyActive = (status && status !== 'active') || brand || size || storage || text || rangeFromVal || rangeToVal;
document.getElementById('filter-reset').style.display = anyActive ? '' : 'none';
var rows = document.querySelectorAll('tbody tr[data-brand]');
var filtered = 0;
rows.forEach(function(row) {
var show = true;
if (status === 'active') {
if (row.dataset.status === 'retired') show = false;
} else if (status) {
if (row.dataset.status !== status) show = false;
}
if (brand && row.dataset.brand !== brand) show = false;
if (size && row.dataset.size !== size) show = false;
if (storage && row.dataset.storage !== storage) show = false;
if (text && row.textContent.toLowerCase().indexOf(text) === -1) show = false;
if (rangeMin !== null || rangeMax !== null) {
var lbl = row.dataset.label || '';
var m = lbl.match(/(\d+)\s*$/);
var n = m ? parseInt(m[1], 10) : null;
if (n === null) show = false;
else if (rangeMin !== null && n < rangeMin) show = false;
else if (rangeMax !== null && n > rangeMax) show = false;
}
row.dataset.filteredOut = show ? '' : '1';
if (show) filtered++;
});
var fc = document.getElementById('filter-count');
fc.textContent = anyActive ? (filtered + ' of ' + rows.length + ' shown') : '';
currentPage = 1;
applyPagination();
}
function applyPagination() {
var allRows = Array.from(document.querySelectorAll('tbody tr[data-brand]'));
var visible = allRows.filter(function(r) { return !r.dataset.filteredOut; });
var totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE));
if (currentPage > totalPages) currentPage = totalPages;
var start = (currentPage - 1) * PAGE_SIZE;
allRows.forEach(function(r) { r.style.display = 'none'; });
visible.slice(start, start + PAGE_SIZE).forEach(function(r) { r.style.display = ''; });
var info = document.getElementById('page-info');
var prev = document.getElementById('prev-page');
var next = document.getElementById('next-page');
if (info) info.textContent = visible.length > PAGE_SIZE
? 'Page ' + currentPage + ' of ' + totalPages : '';
if (prev) prev.disabled = currentPage <= 1;
if (next) next.disabled = currentPage >= totalPages;
updateToolbar();
}
function confirmInstallDevice(btn) {
var deviceSel = document.getElementById('bulk-device-select');
if (!deviceSel.value) { deviceSel.focus(); return; }
var movers = Array.prototype.filter.call(
document.querySelectorAll('.row-cb:checked'),
function(cb) { return cb.closest('tr').dataset.status === 'installed'; }
);
if (movers.length > 0) {
var n = movers.length;
showConfirm(
n + ' selected batter' + (n === 1 ? 'y is' : 'ies are') +
' already installed elsewhere. Unassign and move to the selected device?',
function() { submitWithAction(btn); },
'Move', 'btn-warning'
);
} else {
submitWithAction(btn);
}
}
function bulkActionConfirm(btn, msg, okLabel, okClass) {
showConfirm(msg, function() { submitWithAction(btn); }, okLabel, okClass);
}
function submitWithAction(btn) {
var form = btn.form || document.getElementById('bulk-form');
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = btn.name; inp.value = btn.value;
form.appendChild(inp);
form.submit();
}
function quickAssign(action, batteryId) {
var sel = document.getElementById('qas-' + batteryId);
if (!sel.value) { sel.focus(); return; }
var f = document.createElement('form');
f.method = 'post'; f.action = action;
var deviceInp = document.createElement('input');
deviceInp.type = 'hidden'; deviceInp.name = 'device_id'; deviceInp.value = sel.value;
f.appendChild(deviceInp);
var csrfInp = document.createElement('input');
csrfInp.type = 'hidden'; csrfInp.name = 'csrf_token'; csrfInp.value = CSRF_TOKEN;
f.appendChild(csrfInp);
document.body.appendChild(f);
f.submit();
}
function resetFilters() {
document.getElementById('filter-status').value = 'active';
['filter-brand','filter-size','filter-storage'].forEach(function(id) {
document.getElementById(id).value = '';
});
document.getElementById('filter-text').value = '';
document.getElementById('filter-range-from').value = '';
document.getElementById('filter-range-to').value = '';
applyFilters();
}
function updateBulkField(sel) {
var field = sel.value;
document.getElementById('bulk-field-name').value = field;
document.getElementById('bulk-val-storage_location').style.display = field === 'storage_location' ? 'flex' : 'none';
document.getElementById('bulk-val-brand').style.display = field === 'brand' ? 'flex' : 'none';
document.getElementById('bulk-field-value-storage').disabled = (field !== 'storage_location');
document.getElementById('bulk-field-value-brand').disabled = (field !== 'brand');
}
// initialise disabled state on page load
document.getElementById('bulk-field-value-brand').disabled = true;
updateToolbar();
// Column picker
var COL_KEY = 'battery_cols';
var ALL_COLS = ['last-charged','health','chemistry','capacity','storage','purchase','cycles'{% if ha_enabled %},'ha-pct'{% endif %}];
function toggleCol(cb) {
var col = cb.dataset.col;
document.querySelectorAll('.col-' + col).forEach(function(el) {
el.style.display = cb.checked ? '' : 'none';
});
var prefs = JSON.parse(localStorage.getItem(COL_KEY) || '{}');
prefs[col] = cb.checked;
localStorage.setItem(COL_KEY, JSON.stringify(prefs));
}
(function loadColPrefs() {
var prefs = JSON.parse(localStorage.getItem(COL_KEY) || '{}');
ALL_COLS.forEach(function(col) {
if (prefs[col]) {
var cb = document.querySelector('[data-col="' + col + '"]');
if (cb) { cb.checked = true; toggleCol(cb); }
}
});
}());
// Relative age for "Last Charged" column
function relAge(dateStr) {
if (!dateStr) return {text: 'Never', cls: 'text-danger'};
var days = Math.floor((Date.now() - new Date(dateStr + 'T00:00:00')) / 86400000);
if (days <= 0) return {text: 'Today', cls: ''};
if (days === 1) return {text: 'Yesterday', cls: ''};
if (days < 14) return {text: days + ' days ago', cls: ''};
if (days < 60) return {text: Math.floor(days / 7) + ' wks ago', cls: days > 30 ? 'text-warning' : ''};
if (days < 365) return {text: Math.floor(days / 30) + ' mo ago', cls: days > 180 ? 'text-danger' : 'text-warning'};
return {text: Math.floor(days / 365) + ' yr ago', cls: 'text-danger'};
}
document.querySelectorAll('td[data-charged]').forEach(function(td) {
var r = relAge(td.dataset.charged);
td.innerHTML = '<span class="' + r.cls + '">' + r.text + '</span>';
});
// Sortable columns
var _sortCol = null, _sortDir = 1;
var _origOrder = null;
function _captureOrder() {
if (!_origOrder) {
_origOrder = Array.from(document.querySelectorAll('tbody tr[data-brand]'));
}
}
document.querySelectorAll('th[data-sortable]').forEach(function(th) {
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
var ind = document.createElement('span');
ind.className = 'sort-ind';
ind.style.fontSize = '0.7rem';
th.appendChild(ind);
th.addEventListener('click', function() {
var col = th.dataset.sortable;
if (_sortCol === col) {
_sortDir = _sortDir === 1 ? -1 : 0;
} else {
_sortCol = col; _sortDir = 1;
}
document.querySelectorAll('th[data-sortable] .sort-ind').forEach(function(s) { s.textContent = ''; });
if (_sortDir === 0) {
_sortCol = null; _sortDir = 1;
_captureOrder();
var tbody = document.querySelector('tbody');
_origOrder.forEach(function(r) { tbody.appendChild(r); });
applyPagination();
return;
}
ind.textContent = _sortDir === 1 ? ' ▲' : ' ▼';
_captureOrder();
var tbody = document.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr[data-brand]'));
rows.sort(function(a, b) {
var at = a.querySelector('td[data-sort-col="' + col + '"]');
var bt = b.querySelector('td[data-sort-col="' + col + '"]');
var av = at ? (at.dataset.sort || '') : '';
var bv = bt ? (bt.dataset.sort || '') : '';
// empty values always sort last
if (!av && !bv) return 0;
if (!av) return 1;
if (!bv) return -1;
var an = parseFloat(av), bn = parseFloat(bv);
if (!isNaN(an) && !isNaN(bn)) return (an - bn) * _sortDir;
return av.localeCompare(bv) * _sortDir;
});
rows.forEach(function(r) { tbody.appendChild(r); });
applyPagination();
});
});
document.getElementById('col-picker-btn').addEventListener('click', function(e) {
var panel = document.getElementById('col-picker-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
e.stopPropagation();
});
document.addEventListener('click', function() {
document.getElementById('col-picker-panel').style.display = 'none';
});
function validateBulkCharge() {
var d = document.getElementById('bulk-charged-date');
if (!d.value) { d.focus(); return false; }
return true;
}
function bulkStorageChanged(sel) {
var text = document.getElementById('bulk-storage-text');
var hidden = document.getElementById('bulk-field-value-storage');
if (sel.value === '__new__') {
text.style.display = '';
text.value = '';
text.oninput = function() { hidden.value = text.value; };
text.focus();
hidden.value = '';
} else {
text.style.display = 'none';
hidden.value = sel.value;
}
}
applyFilters();
</script>
{% endblock %}
+1 -1
View File
@@ -108,7 +108,7 @@
</table> </table>
</div> </div>
{% if device.children or not device.is_subcomponent() %} {% if device.children or device.can_have_subcomponents() %}
<div class="card"> <div class="card">
{% if device.children %} {% if device.children %}
<h2>Sub-components</h2> <h2>Sub-components</h2>
+3
View File
@@ -5,6 +5,9 @@
<h1>Devices</h1> <h1>Devices</h1>
<div class="card"> <div class="card">
<div style="display:flex;justify-content:flex-start;margin-bottom:0.5rem;">
<a class="btn btn-primary btn-sm" href="{{ url_for('device_add') }}">+ Add Device</a>
</div>
<div id="device-filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;"> <div id="device-filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;">
<select id="filter-type" onchange="applyDeviceFilters()" <select id="filter-type" onchange="applyDeviceFilters()"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;"> style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Home — Battery Tracker{% endblock %}
{% block content %}
<h1>Battery Tracker</h1>
<h2 style="font-size:1rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.6rem;">Batteries</h2>
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<a class="stat-link" href="{{ url_for('battery_list') }}?status=">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;">{{ total_batteries }}</div>
<div class="text-muted">Total</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('battery_list') }}?status=available">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-available);">{{ avail_count }}</div>
<div class="text-muted">Available</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('battery_list') }}?status=installed">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-installed);">{{ installed_count }}</div>
<div class="text-muted">Installed</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('battery_list') }}?status=retired">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-retired);">{{ retired_count }}</div>
<div class="text-muted">Retired</div>
</div>
</a>
{% if ha_enabled and needs_attention.low_pct %}
<a href="#needs-attention"
onclick="var d=document.getElementById('needs-attention');d.open=true;"
style="flex:1;min-width:120px;text-decoration:none;">
<div class="card" style="text-align:center;border:2px solid #f59e0b;cursor:pointer;">
<div style="font-size:1.8rem;font-weight:700;color:#f59e0b;">{{ needs_attention.low_pct|length }}</div>
<div class="text-muted">Low Battery</div>
</div>
</a>
{% endif %}
</div>
<h2 style="font-size:1rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.6rem;">Devices</h2>
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<a class="stat-link" href="{{ url_for('device_list') }}">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;">{{ total_devices }}</div>
<div class="text-muted">Total</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('device_list') }}?fill=full">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-installed);">{{ full_devices }}</div>
<div class="text-muted">Full</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('device_list') }}?fill=partial">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-available);">{{ partial_devices }}</div>
<div class="text-muted">Partial</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('device_list') }}?fill=empty">
<div class="card" style="text-align:center;{% if empty_devices > 0 %}border:2px solid #f59e0b;{% endif %}">
<div style="font-size:1.8rem;font-weight:700;{% if empty_devices > 0 %}color:#f59e0b;{% else %}color:var(--count-retired);{% endif %}">{{ empty_devices }}</div>
<div class="text-muted">Empty</div>
</div>
</a>
</div>
{% if total_charges %}
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1rem;">
<span class="badge" style="font-size:0.875rem;padding:0.35rem 0.75rem;">Charged <strong>{{ total_charges }}</strong>&times; total</span>
<span class="badge" style="font-size:0.875rem;padding:0.35rem 0.75rem;"><strong>{{ charges_last_year }}</strong>&times; in last year</span>
</div>
{% endif %}
{% set na_low_cap = needs_attention.low_capacity %}
{% set na_low_pct = needs_attention.low_pct %}
{% if na_low_cap or na_low_pct %}
<details id="needs-attention" class="card" style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-weight:600;color:var(--text-warning);list-style:none;display:flex;align-items:center;gap:0.5rem;">
<span>&#9888;</span>
<span>Needs Attention &nbsp;<span class="badge badge-warning">{{ (na_low_cap|length) + (na_low_pct|length) }}</span></span>
</summary>
<div style="margin-top:0.75rem;display:flex;flex-wrap:wrap;gap:1.5rem;">
{% if na_low_cap %}
<div>
<div style="font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.4rem;">Low Capacity (&lt;80%)</div>
{% for b in na_low_cap %}
<div style="font-size:0.875rem;margin-bottom:0.25rem;">
<a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a>
<span class="text-muted">— {{ (b.tested_capacity_mah / b.capacity_mah * 100)|int }}% of rated</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if na_low_pct %}
<div>
<div style="font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.4rem;">Low Battery %</div>
{% for b in na_low_pct %}
<div style="font-size:0.875rem;margin-bottom:0.25rem;">
<a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a>
<span class="text-muted">— {{ b.battery_percentage }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endif %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-top:0.5rem;max-width:480px;">
<a class="btn btn-primary" style="text-align:center;" href="{{ url_for('battery_add') }}">+ Add Battery</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('battery_list') }}">View All Batteries</a>
<a class="btn btn-primary" style="text-align:center;" href="{{ url_for('device_add') }}">+ Add Device</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('device_list') }}">View All Devices</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('export_page') }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Export Data</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('import_page') }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Import Data</a>
</div>
{% endblock %}
+1 -1
View File
@@ -65,7 +65,7 @@
</tbody> </tbody>
</table> </table>
<div style="margin-top:1.25rem;display:flex;gap:0.75rem;flex-wrap:wrap;"> <div style="margin-top:1.25rem;display:flex;gap:0.75rem;flex-wrap:wrap;">
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Go to Dashboard</a> <a href="{{ url_for('home') }}" class="btn btn-primary">Go to Home</a>
<a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import Another File</a> <a href="{{ url_for('import_page') }}" class="btn btn-secondary">Import Another File</a>
</div> </div>
</div> </div>
+44 -11
View File
@@ -8,7 +8,8 @@ The `seeded_client` fixture pre-populates:
BrandX 002 (id=3, retired) BrandX 002 (id=3, retired)
""" """
import pytest import io
import json as _json
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -30,17 +31,52 @@ def follow(client, resp):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Dashboard # Home page
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_dashboard_loads(seeded_client): def test_home_loads(seeded_client):
resp = seeded_client.get("/") resp = seeded_client.get("/")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Available" in resp.data
assert b"Installed" in resp.data
assert b"Devices" in resp.data
assert b"BrandX 001" not in resp.data # no battery table on home page
def test_home_needs_attention_shows_low_capacity(client):
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
client.post("/battery/1/edit-details", data={"capacity_mah": "2000"})
client.post("/battery/1/capacity-test/add",
data={"tested_capacity_mah": "1500", "tested_date": "2026-01-01"})
resp = client.get("/")
assert b"Needs Attention" in resp.data
assert b"Eneloop 001" in resp.data
def test_home_shows_device_counts(seeded_client):
resp = seeded_client.get("/")
assert resp.status_code == 200
assert b"Devices" in resp.data
# ------------------------------------------------------------------ #
# Battery list
# ------------------------------------------------------------------ #
def test_battery_list_loads(seeded_client):
resp = seeded_client.get("/battery/")
assert resp.status_code == 200
assert b"BrandX 001" in resp.data assert b"BrandX 001" in resp.data
assert b"BrandY 001" in resp.data assert b"BrandY 001" in resp.data
assert b"BrandX 002" in resp.data assert b"BrandX 002" in resp.data
def test_battery_list_has_filter_bar(seeded_client):
resp = seeded_client.get("/battery/")
assert b"filter-status" in resp.data
assert b"filter-brand" in resp.data
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery add — label preview data # Battery add — label preview data
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -134,7 +170,7 @@ def test_edit_details_percentage_valid(seeded_client):
data = _json.loads(seeded_client.get("/export/all.json").data) data = _json.loads(seeded_client.get("/export/all.json").data)
bat = next(b for b in data["batteries"] if b["id"] == 1) bat = next(b for b in data["batteries"] if b["id"] == 1)
assert bat["battery_percentage"] == 100 assert bat["battery_percentage"] == 100
assert any(l["battery_id"] == 1 and l["source"] == "manual" for l in data["pct_logs"]) assert any(entry["battery_id"] == 1 and entry["source"] == "manual" for entry in data["pct_logs"])
def test_assign_battery(seeded_client): def test_assign_battery(seeded_client):
@@ -197,7 +233,7 @@ def test_unassign_next_honors_local_path(seeded_client):
def test_unassign_next_rejects_external_redirect(seeded_client): def test_unassign_next_rejects_external_redirect(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"}) seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"}) resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"})
assert resp.headers["Location"] == "/" assert resp.headers["Location"] == "/battery/"
def test_unassign_all_next_rejects_external_redirect(seeded_client): def test_unassign_all_next_rejects_external_redirect(seeded_client):
@@ -664,9 +700,6 @@ def test_bulk_install_filters_by_size(client):
# Import # Import
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
import io
import json as _json
def _make_import_payload(devices=None, batteries=None, def _make_import_payload(devices=None, batteries=None,
charge_logs=None, capacity_tests=None, pct_logs=None): charge_logs=None, capacity_tests=None, pct_logs=None):
@@ -741,7 +774,7 @@ def test_import_creates_devices_and_batteries(client):
resp = _post_import(client, payload) resp = _post_import(client, payload)
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Import Results" in resp.data assert b"Import Results" in resp.data
dash = client.get("/") dash = client.get("/battery/")
assert b"Eneloop 001" in dash.data assert b"Eneloop 001" in dash.data
assert b"RC Car" in dash.data assert b"RC Car" in dash.data
@@ -1054,10 +1087,10 @@ def test_subcomponent_install_batteries(client):
assert b"full" in resp.data.lower() assert b"full" in resp.data.lower()
def test_dashboard_shows_parent_slash_child(client): def test_battery_list_shows_parent_slash_child(client):
_setup_rc_car(client) _setup_rc_car(client)
client.post("/device/2/install-one", data={"battery_id": "1"}) client.post("/device/2/install-one", data={"battery_id": "1"})
resp = client.get("/", follow_redirects=True) resp = client.get("/battery/", follow_redirects=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert b"RC Car Set" in resp.data assert b"RC Car Set" in resp.data
assert b"Remote" in resp.data assert b"Remote" in resp.data
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -314,7 +314,7 @@ def test_poll_handles_api_error_gracefully(ha_app, ha_client_f):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_dashboard_shows_ha_column_when_enabled(ha_client_f): def test_dashboard_shows_ha_column_when_enabled(ha_client_f):
resp = ha_client_f.get("/") resp = ha_client_f.get("/battery/")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"ha-pct" in resp.data assert b"ha-pct" in resp.data
@@ -351,7 +351,7 @@ def test_dashboard_no_warning_for_high_percentage(ha_app, ha_client_f):
s.commit() s.commit()
s.close() s.close()
resp = ha_client_f.get("/") resp = ha_client_f.get("/battery/")
assert b"85%" in resp.data assert b"85%" in resp.data
# badge-warning should NOT appear for this battery's percentage # badge-warning should NOT appear for this battery's percentage
# (may still appear in page for other reasons, so check row contains 85% but not warning badge near it) # (may still appear in page for other reasons, so check row contains 85% but not warning badge near it)
@@ -484,7 +484,8 @@ def test_poll_skips_update_when_percentage_unchanged(ha_app, ha_client_f):
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"]) engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
s = sessionmaker(bind=engine)() s = sessionmaker(bind=engine)()
s.get(Battery, 1).battery_percentage = 50 s.get(Battery, 1).battery_percentage = 50
s.commit(); s.close() s.commit()
s.close()
from ha_client import HomeAssistantClient from ha_client import HomeAssistantClient
from ha_poller import HaPoller from ha_poller import HaPoller