Compare commits

..
18 Commits
Author SHA1 Message Date
iterminate bd171d2384 Add Gitea CI workflow; fix ruff lint issues
CI / test (push) Successful in 1m16s
2026-06-28 07:44:04 -05:00
iterminate 129b1eff04 Nav active highlighting; reorder add battery form; export/import icons 2026-06-21 13:20:17 -05:00
iterminate 18456cf8a4 Clickable home page stat cards; clean up nav; add buttons on list pages; move export/import to home 2026-06-21 12:46:51 -05:00
iterminate 0b893ffcbb Split dashboard into home page and battery list page 2026-06-19 21:43:10 -05:00
iterminate 1980453cab Add 110 tests; raise coverage from 85% to 99% 2026-06-12 08:32:58 -05:00
iterminate c0115aebf9 Gate sub-components section on eligible devices via can_have_subcomponents() 2026-06-12 08:32:53 -05:00
iterminate ff1a883170 Update README test count 2026-06-10 12:40:37 -05:00
iterminate 0223b820be Show parent prefix on assign page; confirm install and charge-all actions 2026-06-10 12:40:13 -05:00
iterminate 1309c2f5db Show sub-components as indented rows in device list 2026-06-10 12:40:13 -05:00
iterminate faeb8e8c04 Add tests for unretire, logbook, charge-log delete, CSV exports, sub-component edit 2026-06-10 12:36:58 -05:00
iterminate b192424978 Update CLAUDE.md models and HA sections for sub-component hierarchy 2026-06-10 12:35:18 -05:00
iterminate 6f0dfb0b7f Move shared CSS/JS from templates to static/style.css and static/app.js 2026-06-10 12:34:53 -05:00
iterminate 7ed7bb2bb0 Extract _filter_compatible helper for size-compatible battery queries 2026-06-10 12:32:03 -05:00
iterminate 17bad3f36b Validate battery percentage range on manual edit; clamp HA values to 0-100 2026-06-10 12:31:11 -05:00
iterminate a9835fee1e Block devices with sub-components from becoming sub-components via forged edit 2026-06-10 12:29:18 -05:00
iterminate 2a103f52a5 Update sub-component batteries from parent HA entity in poller and live sync 2026-06-10 12:28:27 -05:00
iterminate d41fe7f4ab Preserve sub-component hierarchy in export/import 2026-06-10 12:27:05 -05:00
iterminate d1fc80164f Reject protocol-relative URLs in next redirect validation 2026-06-10 11:45:45 -05:00
25 changed files with 3035 additions and 576 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
+4 -3
View File
@@ -41,8 +41,9 @@ journalctl --user -u battery-tracker -f
### Models ### Models
- `Battery`: label (unique), brand, status (`available`/`installed`/`retired`), device_id (FK nullable, `ondelete=SET NULL`), notes, size, chemistry, capacity_mah, tested_capacity_mah, tested_date, charge_cycles, purchase_date, storage_location, battery_percentage - `Battery`: label (unique), brand, status (`available`/`installed`/`retired`), device_id (FK nullable, `ondelete=SET NULL`), notes, size, chemistry, capacity_mah, tested_capacity_mah, tested_date, charge_cycles, purchase_date, storage_location, battery_percentage
- `Device`: name (unique), battery_slots, device_type, notes, ha_entity_id - `Device`: name, battery_slots, device_type, battery_size, location, notes, ha_entity_id, parent_id (self-FK, `ondelete=SET NULL`), parent_key (`-1` for top-level, else parent_id; uniqueness is `UNIQUE(parent_key, name)` so names are scoped per parent). Sub-components are one level deep only; parents have battery_slots=0, battery_size/device_type/location NULL.
- Helper methods: `Battery.is_available/installed/retired()`, `Device.installed_count()`, `Device.installed_brands()`, `Device.has_mixed_brands()` - History/log models: `ChargeLog`, `CapacityTest`, `BatteryPctLog` (per-battery, FK), `Logbook` (attached to a battery or device via association tables, cascade delete-orphan)
- Helper methods: `Battery.is_available/installed/retired()`, `Device.installed_count()`, `Device.installed_brands()`, `Device.has_mixed_brands()`, `Device.has_children()`, `Device.is_subcomponent()`, `Device.effective_installed_count()/effective_slots()` (aggregate over children), `Device.installed_batteries()` (installed batteries incl. sub-components)
### Business rules (enforced in routes, not DB constraints) ### Business rules (enforced in routes, not DB constraints)
- Assigning a retired battery → hard block with flash error - Assigning a retired battery → hard block with flash error
@@ -59,7 +60,7 @@ journalctl --user -u battery-tracker -f
The dashboard route builds an `active` list (`status in ("available", "installed")`) used for all warning logic. Client-side filtering uses `data-status` attributes on each table row and `applyFilters()` in JS. The default filter state is `"active"` (retired rows hidden on page load); the Reset button restores `"active"`, not an empty filter. Column visibility choices are stored in `localStorage`. The dashboard route builds an `active` list (`status in ("available", "installed")`) used for all warning logic. Client-side filtering uses `data-status` attributes on each table row and `applyFilters()` in JS. The default filter state is `"active"` (retired rows hidden on page load); the Reset button restores `"active"`, not an empty filter. Column visibility choices are stored in `localStorage`.
### Home Assistant integration (optional) ### Home Assistant integration (optional)
`ha_client.py` wraps the HA REST API (`GET /api/states/<entity_id>`). `ha_poller.py` runs a daemon thread started in `create_app` only when `HOMEASSISTANT_URL` and `HOMEASSISTANT_API_KEY` are set. The poller queries all `Device` rows with `ha_entity_id IS NOT NULL`, fetches the current percentage from HA, and writes it to `battery_percentage` on each installed battery in that device. The poller uses its own `sessionmaker` session (not the request-scoped `scoped_session`). When HA is not configured the app behaves exactly as before — all HA UI is gated on `ha_enabled` passed to templates. `ha_client.py` wraps the HA REST API (`GET /api/states/<entity_id>`). `ha_poller.py` runs a daemon thread started in `create_app` only when `HOMEASSISTANT_URL` and `HOMEASSISTANT_API_KEY` are set. The poller queries all `Device` rows with `ha_entity_id IS NOT NULL`, fetches the current percentage from HA (clamped to 0100), and writes it to `battery_percentage` on each installed battery in that device — including batteries installed in its sub-components (`Device.installed_batteries()`). The poller uses its own `sessionmaker` session (not the request-scoped `scoped_session`). When HA is not configured the app behaves exactly as before — all HA UI is gated on `ha_enabled` passed to templates.
### Adding new columns to existing DB ### Adding new columns to existing DB
`create_all()` won't add columns to existing tables. Run via Python: `create_all()` won't add columns to existing tables. Run via Python:
+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 (82 tests) | | Tests | pytest (255 tests, 99% coverage) |
--- ---
+157 -103
View File
@@ -23,6 +23,22 @@ def _parse_date(val: str) -> str | None:
return None return None
def _safe_next(default_url):
"""Return the form's `next` URL only if it is a local path (rejects
protocol-relative `//host` and `/\\host` redirects)."""
nxt = request.form.get("next", "")
if nxt.startswith("/") and not nxt.startswith("//") and not nxt.startswith("/\\"):
return nxt
return default_url
def _filter_compatible(query, battery_size):
"""Restrict a Battery query to size-compatible batteries (matching size or unsized)."""
if battery_size:
query = query.filter((Battery.size == battery_size) | (Battery.size == None)) # noqa: E711
return query
def _record_charge(db, battery, date_val, increment, notes): def _record_charge(db, battery, date_val, increment, notes):
"""Apply one charge event to battery. Caller must call db.commit().""" """Apply one charge event to battery. Caller must call db.commit()."""
if increment: if increment:
@@ -81,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")
@@ -90,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": [
@@ -122,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)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -185,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 = [
@@ -236,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,
@@ -284,6 +331,9 @@ def create_app(config_object="config"):
battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None
battery.storage_location = f.get("storage_location", "").strip() or None battery.storage_location = f.get("storage_location", "").strip() or None
new_pct = _int("battery_percentage") new_pct = _int("battery_percentage")
if new_pct is not None and not (0 <= new_pct <= 100):
flash("Battery percentage must be between 0 and 100.", "error")
return redirect(url_for("battery_detail", battery_id=battery_id))
if new_pct != battery.battery_percentage: if new_pct != battery.battery_percentage:
battery.battery_percentage = new_pct battery.battery_percentage = new_pct
if new_pct is not None: if new_pct is not None:
@@ -435,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)
@@ -452,8 +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")
next_url = request.form.get("next", "") return redirect(_safe_next(url_for("battery_list")))
return redirect(next_url if next_url.startswith("/") else url_for("dashboard"))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — retire # Battery — retire
@@ -471,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
@@ -504,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)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -516,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")
@@ -545,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()
@@ -554,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()]
@@ -571,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:
@@ -580,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)
@@ -609,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()
@@ -622,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)
@@ -636,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
@@ -650,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})
@@ -776,17 +825,14 @@ def create_app(config_object="config"):
if device is None: if device is None:
abort(404) abort(404)
all_devices = db.query(Device).all() all_devices = db.query(Device).all()
brands_q = db.query(Battery.brand).filter(Battery.status == "available") brands_q = _filter_compatible(
if device.battery_size: db.query(Battery.brand).filter(Battery.status == "available"),
brands_q = brands_q.filter( device.battery_size,
(Battery.size == device.battery_size) | (Battery.size == None) )
)
brands = [r[0] for r in brands_q.distinct().order_by(Battery.brand).all()] brands = [r[0] for r in brands_q.distinct().order_by(Battery.brand).all()]
avail_q = db.query(Battery).filter_by(status="available") avail_q = _filter_compatible(
if device.battery_size: db.query(Battery).filter_by(status="available"), device.battery_size
avail_q = avail_q.filter( )
(Battery.size == device.battery_size) | (Battery.size == None)
)
available_batteries = avail_q.order_by(Battery.label).all() available_batteries = avail_q.order_by(Battery.label).all()
device_types = sorted({d.device_type for d in all_devices if d.device_type}) device_types = sorted({d.device_type for d in all_devices if d.device_type})
device_locations = sorted({d.location for d in all_devices if d.location}) device_locations = sorted({d.location for d in all_devices if d.location})
@@ -795,9 +841,10 @@ def create_app(config_object="config"):
if ha_client.enabled and device.ha_entity_id: if ha_client.enabled and device.ha_entity_id:
ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1) ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1)
if ha_live_pct is not None: if ha_live_pct is not None:
ha_live_pct = max(0, min(100, ha_live_pct))
changed = False changed = False
for battery in device.batteries: for battery in device.installed_batteries():
if battery.status == "installed" and battery.battery_percentage != ha_live_pct: if battery.battery_percentage != ha_live_pct:
battery.battery_percentage = ha_live_pct battery.battery_percentage = ha_live_pct
db.add(BatteryPctLog( db.add(BatteryPctLog(
battery_id=battery.id, battery_id=battery.id,
@@ -815,11 +862,9 @@ def create_app(config_object="config"):
for b in child.batteries: for b in child.batteries:
if b.status == "installed": if b.status == "installed":
flat_installed.append((b, child.name, child.id)) flat_installed.append((b, child.name, child.id))
avail_q = db.query(Battery).filter_by(status="available") avail_q = _filter_compatible(
if child.battery_size: db.query(Battery).filter_by(status="available"), child.battery_size
avail_q = avail_q.filter( )
(Battery.size == child.battery_size) | (Battery.size == None)
)
children_avail[child.id] = avail_q.order_by(Battery.label).all() children_avail[child.id] = avail_q.order_by(Battery.label).all()
return render_template("device_detail.html", device=device, brands=brands, return render_template("device_detail.html", device=device, brands=brands,
available_batteries=available_batteries, available_batteries=available_batteries,
@@ -881,6 +926,9 @@ def create_app(config_object="config"):
if new_parent.id == device_id: if new_parent.id == device_id:
flash("A device cannot be its own parent.", "error") flash("A device cannot be its own parent.", "error")
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
if device.has_children():
flash("A device with sub-components cannot itself become a sub-component.", "error")
return redirect(url_for("device_detail", device_id=device_id))
if new_parent.is_subcomponent(): if new_parent.is_subcomponent():
flash("Cannot nest sub-components more than one level deep.", "error") flash("Cannot nest sub-components more than one level deep.", "error")
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
@@ -960,11 +1008,10 @@ def create_app(config_object="config"):
# Validate availability before writing anything # Validate availability before writing anything
for brand, qty in pairs: for brand, qty in pairs:
avail_q = db.query(func.count(Battery.id)).filter_by(brand=brand, status="available") avail_q = _filter_compatible(
if device.battery_size: db.query(func.count(Battery.id)).filter_by(brand=brand, status="available"),
avail_q = avail_q.filter( device.battery_size,
(Battery.size == device.battery_size) | (Battery.size == None) )
)
available_count = avail_q.scalar() available_count = avail_q.scalar()
if available_count < qty: if available_count < qty:
flash( flash(
@@ -976,11 +1023,10 @@ def create_app(config_object="config"):
# All checks passed — perform installs # All checks passed — perform installs
total_installed = 0 total_installed = 0
for brand, qty in pairs: for brand, qty in pairs:
batch_q = db.query(Battery).filter_by(brand=brand, status="available") batch_q = _filter_compatible(
if device.battery_size: db.query(Battery).filter_by(brand=brand, status="available"),
batch_q = batch_q.filter( device.battery_size,
(Battery.size == device.battery_size) | (Battery.size == None) )
)
batch = batch_q.order_by(Battery.id).limit(qty).all() batch = batch_q.order_by(Battery.id).limit(qty).all()
for b in batch: for b in batch:
b.status = "installed" b.status = "installed"
@@ -1082,10 +1128,7 @@ def create_app(config_object="config"):
f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.", f"Unassigned {count} batter{'y' if count == 1 else 'ies'} from {device.name}.",
"success", "success",
) )
nxt = request.form.get("next", "") return redirect(_safe_next(url_for("device_list")))
if nxt.startswith("/"):
return redirect(nxt)
return redirect(url_for("device_list"))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — batch install specific batteries # Devices — batch install specific batteries
@@ -1139,15 +1182,7 @@ def create_app(config_object="config"):
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
increment = 1 if request.form.get("increment_cycles") else 0 increment = 1 if request.form.get("increment_cycles") else 0
notes = request.form.get("notes", "").strip() or None notes = request.form.get("notes", "").strip() or None
if device.has_children(): installed = device.installed_batteries()
installed = [
b
for child in device.children
for b in child.batteries
if b.status == "installed"
]
else:
installed = [b for b in device.batteries if b.status == "installed"]
if not installed: if not installed:
flash("No installed batteries to log.", "warning") flash("No installed batteries to log.", "warning")
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
@@ -1188,11 +1223,13 @@ def create_app(config_object="config"):
buf = io.StringIO() buf = io.StringIO()
w = csv.writer(buf) w = csv.writer(buf)
w.writerow(["id", "name", "battery_slots", "installed_count", w.writerow(["id", "name", "battery_slots", "installed_count",
"device_type", "battery_size", "location", "ha_entity_id", "notes"]) "device_type", "battery_size", "location", "ha_entity_id", "notes",
"parent_id", "parent_name"])
for d in rows: for d in rows:
w.writerow([d.id, d.name, d.battery_slots, d.installed_count(), w.writerow([d.id, d.name, d.battery_slots, d.installed_count(),
d.device_type or "", d.battery_size or "", d.device_type or "", d.battery_size or "",
d.location or "", d.ha_entity_id or "", d.notes or ""]) d.location or "", d.ha_entity_id or "", d.notes or "",
d.parent_id or "", d.parent.name if d.parent else ""])
return buf.getvalue() return buf.getvalue()
def _charge_logs_csv(): def _charge_logs_csv():
@@ -1200,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():
@@ -1220,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")
@@ -1291,14 +1328,16 @@ def create_app(config_object="config"):
{"id": d.id, "name": d.name, "battery_slots": d.battery_slots, {"id": d.id, "name": d.name, "battery_slots": d.battery_slots,
"installed_count": d.installed_count(), "device_type": d.device_type, "installed_count": d.installed_count(), "device_type": d.device_type,
"battery_size": d.battery_size, "location": d.location, "battery_size": d.battery_size, "location": d.location,
"ha_entity_id": d.ha_entity_id, "notes": d.notes} "ha_entity_id": d.ha_entity_id, "notes": d.notes,
"parent_id": d.parent_id,
"parent_name": d.parent.name if d.parent else None}
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,
@@ -1307,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",
@@ -1361,29 +1400,33 @@ def create_app(config_object="config"):
try: try:
# --- devices --- # --- devices ---
for d in data.get("devices", []): def _import_device(d, new_parent_id):
nonlocal devices_created, devices_skipped
old_id = d.get("id") old_id = d.get("id")
name = (d.get("name") or "").strip() name = (d.get("name") or "").strip()
if not name: if not name:
devices_skipped += 1 devices_skipped += 1
continue return
parent_key = new_parent_id if new_parent_id is not None else -1
existing = db.query(Device).filter( existing = db.query(Device).filter(
Device.parent_key == -1, Device.name == name Device.parent_key == parent_key, Device.name == name
).first() ).first()
if existing: if existing:
if old_id is not None: if old_id is not None:
device_id_map[old_id] = existing.id device_id_map[old_id] = existing.id
devices_skipped += 1 devices_skipped += 1
else: else:
slots = d.get("battery_slots")
new_dev = Device( new_dev = Device(
name = name, name = name,
battery_slots = d.get("battery_slots") or 1, battery_slots = slots if slots is not None else 1,
device_type = d.get("device_type") or None, device_type = d.get("device_type") or None,
battery_size = d.get("battery_size") or "", battery_size = d.get("battery_size") or None,
location = d.get("location") or None, location = d.get("location") or None,
ha_entity_id = d.get("ha_entity_id") or None, ha_entity_id = d.get("ha_entity_id") or None,
notes = d.get("notes") or None, notes = d.get("notes") or None,
parent_key = -1, parent_id = new_parent_id,
parent_key = parent_key,
) )
db.add(new_dev) db.add(new_dev)
db.flush() db.flush()
@@ -1391,6 +1434,17 @@ def create_app(config_object="config"):
device_id_map[old_id] = new_dev.id device_id_map[old_id] = new_dev.id
devices_created += 1 devices_created += 1
# Two passes so sub-components can resolve their parent's new id
# regardless of ordering in the payload. A sub whose parent is
# missing from the payload is imported as top-level.
device_rows = data.get("devices", [])
for d in device_rows:
if d.get("parent_id") is None:
_import_device(d, None)
for d in device_rows:
if d.get("parent_id") is not None:
_import_device(d, device_id_map.get(d.get("parent_id")))
# --- batteries --- # --- batteries ---
for b in data.get("batteries", []): for b in data.get("batteries", []):
old_id = b.get("id") old_id = b.get("id")
+4 -3
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:
@@ -49,8 +49,9 @@ class HaPoller:
for device in devices: for device in devices:
pct = self._client.get_state(device.ha_entity_id) pct = self._client.get_state(device.ha_entity_id)
if pct is not None: if pct is not None:
for battery in device.batteries: pct = max(0, min(100, pct))
if battery.status == "installed" and battery.battery_percentage != pct: for battery in device.installed_batteries():
if battery.battery_percentage != pct:
battery.battery_percentage = pct battery.battery_percentage = pct
session.add(BatteryPctLog( session.add(BatteryPctLog(
battery_id=battery.id, battery_id=battery.id,
+10 -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)
@@ -86,6 +88,13 @@ class Device(Base):
return sum(c.battery_slots for c in self.children) return sum(c.battery_slots for c in self.children)
return self.battery_slots return self.battery_slots
def installed_batteries(self):
"""Installed batteries on this device, including its sub-components."""
bats = [b for b in self.batteries if b.status == "installed"]
for child in self.children:
bats.extend(b for b in child.batteries if b.status == "installed")
return bats
def __repr__(self): def __repr__(self):
return f"<Device {self.name}>" return f"<Device {self.name}>"
+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")
+98
View File
@@ -0,0 +1,98 @@
// Shared front-end behavior, loaded with `defer` from base.html.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// Inject CSRF token into all POST forms
document.addEventListener('DOMContentLoaded', function() {
var meta = document.querySelector('meta[name="csrf-token"]');
var token = meta ? meta.content : '';
document.querySelectorAll('form').forEach(function(form) {
if (form.method.toLowerCase() === 'post') {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
form.appendChild(inp);
}
});
});
// "Select from list or type new" pattern on add/edit forms
function metaSelectChanged(sel, inputId) {
var input = document.getElementById(inputId);
if (sel.value === '__new__') {
input.style.display = '';
input.value = '';
input.focus();
} else {
input.style.display = 'none';
input.value = sel.value;
}
// battery_add defines updateLabelPreview to live-preview generated labels
if ((inputId === 'brand' || inputId === 'size') && typeof updateLabelPreview === 'function') {
updateLabelPreview();
}
}
(function() {
var modal = document.getElementById('confirm-modal');
var msgEl = document.getElementById('confirm-modal-msg');
var okBtn = document.getElementById('confirm-modal-ok');
var cancelBtn = document.getElementById('confirm-modal-cancel');
var _cb = null;
window.showConfirm = function(msg, onOk, okLabel, okClass) {
msgEl.textContent = msg;
okBtn.textContent = okLabel || 'Confirm';
okBtn.className = 'btn ' + (okClass || 'btn-danger');
modal.classList.add('open');
_cb = onOk;
cancelBtn.focus();
};
function closeModal() { modal.classList.remove('open'); _cb = null; }
okBtn.addEventListener('click', function() {
var cb = _cb; closeModal(); if (cb) cb();
});
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function(e) {
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
document.addEventListener('submit', function(e) {
var form = e.target;
var msg = form.dataset.confirm;
if (!msg || form.dataset.confirmed) return;
e.preventDefault();
var okLabel = form.dataset.confirmOk || 'Confirm';
var okClass = form.dataset.confirmClass || 'btn-danger';
window.showConfirm(msg, function() {
form.dataset.confirmed = '1';
form.submit();
}, okLabel, okClass);
});
}());
+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" }
+359
View File
@@ -0,0 +1,359 @@
/* ─── Color variables (light mode) ──────────────────────────────── */
:root {
--bg-body: #f5f5f5;
--bg-card: #ffffff;
--bg-th: #f1f5f9;
--bg-hover: #f8fafc;
--bg-input: #ffffff;
--bg-toolbar: #f1f5f9;
--bg-picker: #ffffff;
--text-body: #222222;
--text-muted: #6b7280;
--text-label: #374151;
--text-th: #64748b;
--text-h1: #1e293b;
--text-h2: #334155;
--text-warning: #b45309;
--text-danger: #dc2626;
--border: #e2e8f0;
--border-input: #d1d5db;
--link: #2563eb;
--shadow-card: 0 1px 3px rgba(0,0,0,.1);
--btn-secondary-bg: #e2e8f0;
--btn-secondary-text: #334155;
--btn-secondary-hover: #cbd5e1;
--badge-available-bg: #dcfce7; --badge-available-text: #166534;
--badge-installed-bg: #dbeafe; --badge-installed-text: #1e40af;
--badge-retired-bg: #f1f5f9; --badge-retired-text: #64748b;
--badge-warning-bg: #fef9c3; --badge-warning-text: #854d0e;
--flash-success-bg: #dcfce7; --flash-success-text: #166534; --flash-success-border: #86efac;
--flash-error-bg: #fee2e2; --flash-error-text: #991b1b; --flash-error-border: #fca5a5;
--flash-warning-bg: #fef9c3; --flash-warning-text: #854d0e; --flash-warning-border: #fde047;
--health-good: #166534;
--health-warn: #92400e;
--health-bad: #991b1b;
--count-available: #166534;
--count-installed: #1e40af;
--count-retired: #64748b;
}
/* Confirmation modal */
#confirm-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
#confirm-modal.open { display:flex; }
#confirm-modal-box { background:var(--bg-card); border-radius:8px; padding:1.5rem; max-width:400px; width:calc(100% - 2rem); box-shadow:0 8px 24px rgba(0,0,0,.25); }
#confirm-modal-msg { margin-bottom:1.25rem; color:var(--text-body); font-size:0.95rem; line-height:1.5; }
#confirm-modal-actions { display:flex; gap:0.75rem; justify-content:flex-end; }
/* ─── Dark mode variables ────────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root {
--bg-body: #0f172a;
--bg-card: #1e293b;
--bg-th: #162032;
--bg-hover: #243044;
--bg-input: #1e293b;
--bg-toolbar: #162032;
--bg-picker: #1e293b;
--text-body: #e2e8f0;
--text-muted: #94a3b8;
--text-label: #cbd5e1;
--text-th: #94a3b8;
--text-h1: #f1f5f9;
--text-h2: #cbd5e1;
--text-warning: #fbbf24;
--text-danger: #f87171;
--border: #334155;
--border-input: #475569;
--link: #60a5fa;
--shadow-card: 0 1px 3px rgba(0,0,0,.5);
--btn-secondary-bg: #334155;
--btn-secondary-text: #cbd5e1;
--btn-secondary-hover: #475569;
--badge-available-bg: #14532d; --badge-available-text: #86efac;
--badge-installed-bg: #1e3a8a; --badge-installed-text: #93c5fd;
--badge-retired-bg: #273449; --badge-retired-text: #94a3b8;
--badge-warning-bg: #451a03; --badge-warning-text: #fde68a;
--flash-success-bg: #14532d; --flash-success-text: #86efac; --flash-success-border: #166534;
--flash-error-bg: #450a0a; --flash-error-text: #fca5a5; --flash-error-border: #991b1b;
--flash-warning-bg: #451a03; --flash-warning-text: #fde68a; --flash-warning-border: #92400e;
--health-good: #86efac;
--health-warn: #fde68a;
--health-bad: #fca5a5;
--count-available: #86efac;
--count-installed: #93c5fd;
--count-retired: #94a3b8;
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 15px; background: var(--bg-body); color: var(--text-body); }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Nav */
nav {
background: #1e40af;
color: #fff;
display: flex;
flex-wrap: nowrap;
gap: 0.5rem;
align-items: center;
padding: 0.75rem 1rem;
}
nav .brand { font-weight: 700; font-size: 1.1rem; margin-right: auto; color: #fff; text-decoration: none; }
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:hover { background: #1d4ed8; color: #fff; text-decoration: none; }
nav a.active { background: #1d4ed8; color: #fff; }
/* Layout */
.container { max-width: 960px; margin: 1.5rem auto; padding: 0 1rem; }
/* Flash messages */
.flash { padding: 0.6rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; }
.flash.success { background: var(--flash-success-bg); color: var(--flash-success-text); border: 1px solid var(--flash-success-border); }
.flash.error { background: var(--flash-error-bg); color: var(--flash-error-text); border: 1px solid var(--flash-error-border); }
.flash.warning { background: var(--flash-warning-bg); color: var(--flash-warning-text); border: 1px solid var(--flash-warning-border); }
/* Cards / boxes */
.card { background: var(--bg-card); border-radius: 6px; box-shadow: var(--shadow-card); padding: 1.25rem; margin-bottom: 1rem; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 0.5rem 0.75rem; background: var(--bg-th); font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-th); border-bottom: 2px solid var(--border); }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-hover); }
/* Status badges */
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
.badge-available { background: var(--badge-available-bg); color: var(--badge-available-text); }
.badge-installed { background: var(--badge-installed-bg); color: var(--badge-installed-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); }
/* 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 */
.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-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-danger:hover { background: #b91c1c; }
.btn-warning { background: #d97706; color: #fff; }
.btn-warning:hover { background: #b45309; }
.btn-secondary { background: var(--btn-secondary-bg); color: var(--btn-secondary-text); }
.btn-secondary:hover { background: var(--btn-secondary-hover); }
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.8rem; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-label); }
input[type=text], input[type=number], input[type=date], select, textarea {
width: 100%; padding: 0.45rem 0.65rem; border: 1px solid var(--border-input);
border-radius: 4px; font-size: 0.9rem; font-family: inherit;
background: var(--bg-input); color: var(--text-body);
}
input:focus, select:focus, textarea:focus { outline: 2px solid #3b82f6; border-color: #3b82f6; }
textarea { min-height: 80px; resize: vertical; }
.form-actions { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; margin-top: 1.25rem; }
/* Headings */
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: var(--text-h1); }
h2 { font-size: 1.15rem; margin-bottom: 0.75rem; color: var(--text-h2); }
/* Inline form (for POST buttons in tables) */
form.inline { display: inline; }
/* Text utilities */
.text-warning { color: var(--text-warning); font-size: 0.8rem; }
.text-danger { color: var(--text-danger); }
.text-muted { color: var(--text-muted); font-size: 0.85rem; }
/* Battery health classes */
.health-good { color: var(--health-good); }
.health-warn { color: var(--health-warn); }
.health-bad { color: var(--health-bad); }
/* ─── Dark mode overrides for inline-styled elements ─────────────── */
@media (prefers-color-scheme: dark) {
nav { background: #0c1a3b; }
nav a { color: #93c5fd; }
nav a:hover { background: #1e3a8a; color: #fff; }
nav a.active { background: #1e3a8a; color: #fff; }
nav .brand { color: #e2e8f0; }
#bulk-toolbar { background: var(--bg-toolbar) !important; }
#col-picker-panel { background: var(--bg-picker) !important; border-color: var(--border) !important; }
#col-picker-panel > div:first-child { color: var(--text-th) !important; }
#filter-bar select, #filter-bar input,
#bulk-toolbar select, #bulk-toolbar input[type=text],
#device-filter-bar select, #device-filter-bar input {
background: var(--bg-input) !important;
color: var(--text-body) !important;
border-color: var(--border-input) !important;
}
.responsive-table td select {
background: var(--bg-input);
color: var(--text-body);
border-color: var(--border-input);
}
.device-option {
background: var(--bg-card) !important;
border-color: var(--border) !important;
}
#install-grid select, #install-grid input {
background: var(--bg-input);
color: var(--text-body);
border-color: var(--border-input);
}
}
/* ─── Mobile responsive ─────────────────────────────────────────── */
@media (max-width: 640px) {
/* Nav: compact on small screens */
nav { padding: 0.5rem 0.75rem; gap: 0.35rem; }
nav a { font-size: 0.8rem; padding: 0.2rem 0.35rem; }
nav .brand { font-size: 1rem; }
/* Tap targets */
.btn { padding: 0.5rem 0.9rem; }
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.825rem; }
/* Collapse 2-col form grids */
.form-grid-2col { grid-template-columns: 1fr !important; }
/* Card-style table rows */
.responsive-table thead { display: none; }
.responsive-table tr {
display: block;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.75rem;
padding: 0.25rem 0;
background: var(--bg-card);
}
.responsive-table tr:hover td { background: transparent; }
.responsive-table td {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--bg-th);
border-top: none;
font-size: 0.875rem;
}
.responsive-table td:last-child { border-bottom: none; }
.responsive-table td:empty { display: none; }
.responsive-table td::before {
content: attr(data-label);
font-weight: 600;
color: var(--text-th);
font-size: 0.8rem;
flex-shrink: 0;
margin-right: 0.75rem;
white-space: nowrap;
}
/* Checkbox column: no label prefix, left-aligned */
.responsive-table td[data-label=""] {
justify-content: flex-start;
border-bottom: none;
padding-bottom: 0;
}
/* Actions column: allow wrapping, override nowrap */
.responsive-table td[data-label="Actions"] {
flex-wrap: wrap;
gap: 0.35rem;
white-space: normal !important;
}
.responsive-table td[data-label="Actions"] select {
width: 100%;
max-width: 100% !important;
}
/* Filter bar: full-width controls with larger tap targets */
#filter-bar select,
#filter-bar input[type=text] { flex: 1 1 100%; width: 100% !important; padding: 0.5rem 0.65rem !important; font-size: 0.9rem !important; }
/* Bulk toolbar: prevent text inputs overflowing */
#bulk-toolbar input[type=text] { width: 100% !important; }
/* Install grid on device_detail */
#install-grid { grid-template-columns: 1fr !important; max-width: 100% !important; }
/* Column picker: keep panel inside viewport */
#col-picker-panel { left: 0; right: auto !important; max-width: calc(100vw - 2rem); }
/* form-actions: stack buttons full-width */
.form-actions { flex-direction: column; align-items: stretch; }
.form-actions .btn,
.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; }
}
+1 -1
View File
@@ -16,7 +16,7 @@
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;min-height:44px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;min-height:44px;cursor:pointer;">
<input type="radio" name="device_id" value="{{ device.id }}" style="cursor:pointer;"> <input type="radio" name="device_id" value="{{ device.id }}" style="cursor:pointer;">
<span> <span>
<strong>{{ device.name }}</strong> {% if device.parent %}<span class="text-muted">{{ device.parent.name }} / </span>{% endif %}<strong>{{ device.name }}</strong>
<span class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span> <span class="text-muted">({{ device.installed_count() }}/{{ device.battery_slots }} slots used)</span>
</span> </span>
</label> </label>
+31 -382
View File
@@ -5,324 +5,22 @@
<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">
<style> <meta name="csrf-token" content="{{ csrf_token() }}">
/* ─── Color variables (light mode) ──────────────────────────────── */ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
:root {
--bg-body: #f5f5f5;
--bg-card: #ffffff;
--bg-th: #f1f5f9;
--bg-hover: #f8fafc;
--bg-input: #ffffff;
--bg-toolbar: #f1f5f9;
--bg-picker: #ffffff;
--text-body: #222222;
--text-muted: #6b7280;
--text-label: #374151;
--text-th: #64748b;
--text-h1: #1e293b;
--text-h2: #334155;
--text-warning: #b45309;
--text-danger: #dc2626;
--border: #e2e8f0;
--border-input: #d1d5db;
--link: #2563eb;
--shadow-card: 0 1px 3px rgba(0,0,0,.1);
--btn-secondary-bg: #e2e8f0;
--btn-secondary-text: #334155;
--btn-secondary-hover: #cbd5e1;
--badge-available-bg: #dcfce7; --badge-available-text: #166534;
--badge-installed-bg: #dbeafe; --badge-installed-text: #1e40af;
--badge-retired-bg: #f1f5f9; --badge-retired-text: #64748b;
--badge-warning-bg: #fef9c3; --badge-warning-text: #854d0e;
--flash-success-bg: #dcfce7; --flash-success-text: #166534; --flash-success-border: #86efac;
--flash-error-bg: #fee2e2; --flash-error-text: #991b1b; --flash-error-border: #fca5a5;
--flash-warning-bg: #fef9c3; --flash-warning-text: #854d0e; --flash-warning-border: #fde047;
--health-good: #166534;
--health-warn: #92400e;
--health-bad: #991b1b;
--count-available: #166534;
--count-installed: #1e40af;
--count-retired: #64748b;
}
/* Confirmation modal */
#confirm-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
#confirm-modal.open { display:flex; }
#confirm-modal-box { background:var(--bg-card); border-radius:8px; padding:1.5rem; max-width:400px; width:calc(100% - 2rem); box-shadow:0 8px 24px rgba(0,0,0,.25); }
#confirm-modal-msg { margin-bottom:1.25rem; color:var(--text-body); font-size:0.95rem; line-height:1.5; }
#confirm-modal-actions { display:flex; gap:0.75rem; justify-content:flex-end; }
/* ─── Dark mode variables ────────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root {
--bg-body: #0f172a;
--bg-card: #1e293b;
--bg-th: #162032;
--bg-hover: #243044;
--bg-input: #1e293b;
--bg-toolbar: #162032;
--bg-picker: #1e293b;
--text-body: #e2e8f0;
--text-muted: #94a3b8;
--text-label: #cbd5e1;
--text-th: #94a3b8;
--text-h1: #f1f5f9;
--text-h2: #cbd5e1;
--text-warning: #fbbf24;
--text-danger: #f87171;
--border: #334155;
--border-input: #475569;
--link: #60a5fa;
--shadow-card: 0 1px 3px rgba(0,0,0,.5);
--btn-secondary-bg: #334155;
--btn-secondary-text: #cbd5e1;
--btn-secondary-hover: #475569;
--badge-available-bg: #14532d; --badge-available-text: #86efac;
--badge-installed-bg: #1e3a8a; --badge-installed-text: #93c5fd;
--badge-retired-bg: #273449; --badge-retired-text: #94a3b8;
--badge-warning-bg: #451a03; --badge-warning-text: #fde68a;
--flash-success-bg: #14532d; --flash-success-text: #86efac; --flash-success-border: #166534;
--flash-error-bg: #450a0a; --flash-error-text: #fca5a5; --flash-error-border: #991b1b;
--flash-warning-bg: #451a03; --flash-warning-text: #fde68a; --flash-warning-border: #92400e;
--health-good: #86efac;
--health-warn: #fde68a;
--health-bad: #fca5a5;
--count-available: #86efac;
--count-installed: #93c5fd;
--count-retired: #94a3b8;
}
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 15px; background: var(--bg-body); color: var(--text-body); }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Nav */
nav {
background: #1e40af;
color: #fff;
display: flex;
flex-wrap: nowrap;
gap: 0.5rem;
align-items: center;
padding: 0.75rem 1rem;
}
nav .brand { font-weight: 700; font-size: 1.1rem; margin-right: auto; color: #fff; text-decoration: none; }
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:hover { background: #1d4ed8; color: #fff; text-decoration: none; }
/* Layout */
.container { max-width: 960px; margin: 1.5rem auto; padding: 0 1rem; }
/* Flash messages */
.flash { padding: 0.6rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; }
.flash.success { background: var(--flash-success-bg); color: var(--flash-success-text); border: 1px solid var(--flash-success-border); }
.flash.error { background: var(--flash-error-bg); color: var(--flash-error-text); border: 1px solid var(--flash-error-border); }
.flash.warning { background: var(--flash-warning-bg); color: var(--flash-warning-text); border: 1px solid var(--flash-warning-border); }
/* Cards / boxes */
.card { background: var(--bg-card); border-radius: 6px; box-shadow: var(--shadow-card); padding: 1.25rem; margin-bottom: 1rem; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 0.5rem 0.75rem; background: var(--bg-th); font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-th); border-bottom: 2px solid var(--border); }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-hover); }
/* Status badges */
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
.badge-available { background: var(--badge-available-bg); color: var(--badge-available-text); }
.badge-installed { background: var(--badge-installed-bg); color: var(--badge-installed-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); }
/* 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:hover { text-decoration: none; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-danger:hover { background: #b91c1c; }
.btn-warning { background: #d97706; color: #fff; }
.btn-warning:hover { background: #b45309; }
.btn-secondary { background: var(--btn-secondary-bg); color: var(--btn-secondary-text); }
.btn-secondary:hover { background: var(--btn-secondary-hover); }
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.8rem; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 0.3rem; color: var(--text-label); }
input[type=text], input[type=number], input[type=date], select, textarea {
width: 100%; padding: 0.45rem 0.65rem; border: 1px solid var(--border-input);
border-radius: 4px; font-size: 0.9rem; font-family: inherit;
background: var(--bg-input); color: var(--text-body);
}
input:focus, select:focus, textarea:focus { outline: 2px solid #3b82f6; border-color: #3b82f6; }
textarea { min-height: 80px; resize: vertical; }
.form-actions { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; margin-top: 1.25rem; }
/* Headings */
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: var(--text-h1); }
h2 { font-size: 1.15rem; margin-bottom: 0.75rem; color: var(--text-h2); }
/* Inline form (for POST buttons in tables) */
form.inline { display: inline; }
/* Text utilities */
.text-warning { color: var(--text-warning); font-size: 0.8rem; }
.text-danger { color: var(--text-danger); }
.text-muted { color: var(--text-muted); font-size: 0.85rem; }
/* Battery health classes */
.health-good { color: var(--health-good); }
.health-warn { color: var(--health-warn); }
.health-bad { color: var(--health-bad); }
/* ─── Dark mode overrides for inline-styled elements ─────────────── */
@media (prefers-color-scheme: dark) {
nav { background: #0c1a3b; }
nav a { color: #93c5fd; }
nav a:hover { background: #1e3a8a; color: #fff; }
nav .brand { color: #e2e8f0; }
#bulk-toolbar { background: var(--bg-toolbar) !important; }
#col-picker-panel { background: var(--bg-picker) !important; border-color: var(--border) !important; }
#col-picker-panel > div:first-child { color: var(--text-th) !important; }
#filter-bar select, #filter-bar input,
#bulk-toolbar select, #bulk-toolbar input[type=text],
#device-filter-bar select, #device-filter-bar input {
background: var(--bg-input) !important;
color: var(--text-body) !important;
border-color: var(--border-input) !important;
}
.responsive-table td select {
background: var(--bg-input);
color: var(--text-body);
border-color: var(--border-input);
}
.device-option {
background: var(--bg-card) !important;
border-color: var(--border) !important;
}
#install-grid select, #install-grid input {
background: var(--bg-input);
color: var(--text-body);
border-color: var(--border-input);
}
}
/* ─── Mobile responsive ─────────────────────────────────────────── */
@media (max-width: 640px) {
/* Nav: compact on small screens */
nav { padding: 0.5rem 0.75rem; gap: 0.35rem; }
nav a { font-size: 0.8rem; padding: 0.2rem 0.35rem; }
nav .brand { font-size: 1rem; }
/* Tap targets */
.btn { padding: 0.5rem 0.9rem; }
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.825rem; }
/* Collapse 2-col form grids */
.form-grid-2col { grid-template-columns: 1fr !important; }
/* Card-style table rows */
.responsive-table thead { display: none; }
.responsive-table tr {
display: block;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.75rem;
padding: 0.25rem 0;
background: var(--bg-card);
}
.responsive-table tr:hover td { background: transparent; }
.responsive-table td {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--bg-th);
border-top: none;
font-size: 0.875rem;
}
.responsive-table td:last-child { border-bottom: none; }
.responsive-table td:empty { display: none; }
.responsive-table td::before {
content: attr(data-label);
font-weight: 600;
color: var(--text-th);
font-size: 0.8rem;
flex-shrink: 0;
margin-right: 0.75rem;
white-space: nowrap;
}
/* Checkbox column: no label prefix, left-aligned */
.responsive-table td[data-label=""] {
justify-content: flex-start;
border-bottom: none;
padding-bottom: 0;
}
/* Actions column: allow wrapping, override nowrap */
.responsive-table td[data-label="Actions"] {
flex-wrap: wrap;
gap: 0.35rem;
white-space: normal !important;
}
.responsive-table td[data-label="Actions"] select {
width: 100%;
max-width: 100% !important;
}
/* Filter bar: full-width controls with larger tap targets */
#filter-bar select,
#filter-bar input[type=text] { flex: 1 1 100%; width: 100% !important; padding: 0.5rem 0.65rem !important; font-size: 0.9rem !important; }
/* Bulk toolbar: prevent text inputs overflowing */
#bulk-toolbar input[type=text] { width: 100% !important; }
/* Install grid on device_detail */
#install-grid { grid-template-columns: 1fr !important; max-width: 100% !important; }
/* Column picker: keep panel inside viewport */
#col-picker-panel { left: 0; right: auto !important; max-width: calc(100vw - 2rem); }
/* form-actions: stack buttons full-width */
.form-actions { flex-direction: column; align-items: stretch; }
.form-actions .btn,
.form-actions button { width: 100%; text-align: center; }
}
</style>
</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">
@@ -335,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>
@@ -358,64 +43,28 @@
</div> </div>
</div> </div>
<script> <script src="{{ url_for('static', filename='app.js') }}" defer></script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
// Inject CSRF token into all POST forms <nav class="bottom-nav" aria-label="Main navigation">
document.addEventListener('DOMContentLoaded', function() { {% set ep = request.endpoint or '' %}
var token = '{{ csrf_token() }}'; <a href="{{ url_for('home') }}" class="bottom-nav-item{% if ep == 'home' %} active{% endif %}">
document.querySelectorAll('form').forEach(function(form) { <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">
if (form.method.toLowerCase() === 'post') { <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"/>
var inp = document.createElement('input'); </svg>
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token; <span>Home</span>
form.appendChild(inp); </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>
(function() { <span>Batteries</span>
var modal = document.getElementById('confirm-modal'); </a>
var msgEl = document.getElementById('confirm-modal-msg'); <a href="{{ url_for('device_list') }}" class="bottom-nav-item{% if ep.startswith('device') %} active{% endif %}">
var okBtn = document.getElementById('confirm-modal-ok'); <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">
var cancelBtn = document.getElementById('confirm-modal-cancel'); <rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
var _cb = null; </svg>
<span>Devices</span>
window.showConfirm = function(msg, onOk, okLabel, okClass) { </a>
msgEl.textContent = msg; </nav>
okBtn.textContent = okLabel || 'Confirm';
okBtn.className = 'btn ' + (okClass || 'btn-danger');
modal.classList.add('open');
_cb = onOk;
cancelBtn.focus();
};
function closeModal() { modal.classList.remove('open'); _cb = null; }
okBtn.addEventListener('click', function() {
var cb = _cb; closeModal(); if (cb) cb();
});
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
});
// Global handler: forms with data-confirm attribute
document.addEventListener('submit', function(e) {
var form = e.target;
var msg = form.dataset.confirm;
if (!msg || form.dataset.confirmed) return;
e.preventDefault();
var okLabel = form.dataset.confirmOk || 'Confirm';
var okClass = form.dataset.confirmClass || 'btn-danger';
window.showConfirm(msg, function() {
form.dataset.confirmed = '1';
form.submit();
}, okLabel, okClass);
});
}());
</script>
</body> </body>
</html> </html>
+14 -27
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>
@@ -105,19 +105,6 @@
<script> <script>
var prefixMaxNums = {{ prefix_max_nums|default({})|tojson }}; var prefixMaxNums = {{ prefix_max_nums|default({})|tojson }};
function metaSelectChanged(sel, inputId) {
var input = document.getElementById(inputId);
if (sel.value === '__new__') {
input.style.display = '';
input.value = '';
input.focus();
} else {
input.style.display = 'none';
input.value = sel.value;
}
if (inputId === 'brand' || inputId === 'size') updateLabelPreview();
}
function updateLabelPreview() { function updateLabelPreview() {
var brand = document.getElementById('brand').value.trim(); var brand = document.getElementById('brand').value.trim();
var size = document.getElementById('size').value.trim(); var size = document.getElementById('size').value.trim();
+1 -13
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() {
@@ -427,18 +427,6 @@
}); });
}()); }());
function metaSelectChanged(sel, inputId) {
var input = document.getElementById(inputId);
if (sel.value === '__new__') {
input.style.display = '';
input.value = '';
input.focus();
} else {
input.style.display = 'none';
input.value = sel.value;
}
}
// ── Percentage mini chart ───────────────────────────────────────────────── // ── Percentage mini chart ─────────────────────────────────────────────────
(function() { (function() {
var canvas = document.getElementById('pct-chart'); var canvas = document.getElementById('pct-chart');
+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 %}
-12
View File
@@ -129,17 +129,5 @@ function toggleSubcomponents(cb) {
size.setAttribute('required', ''); size.setAttribute('required', '');
} }
} }
function metaSelectChanged(sel, inputId) {
var input = document.getElementById(inputId);
if (sel.value === '__new__') {
input.style.display = '';
input.value = '';
input.focus();
} else {
input.style.display = 'none';
input.value = sel.value;
}
}
</script> </script>
{% endblock %} {% endblock %}
+11 -3
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>
@@ -133,7 +133,9 @@
{% elif not child_batteries %} {% elif not child_batteries %}
<p class="text-muted" style="margin:0;">No compatible batteries available.</p> <p class="text-muted" style="margin:0;">No compatible batteries available.</p>
{% else %} {% else %}
<form method="post" action="{{ url_for('device_install_batch', device_id=child.id) }}"> <form method="post" action="{{ url_for('device_install_batch', device_id=child.id) }}"
data-confirm="Install the selected batteries into {{ child.name }}?"
data-confirm-ok="Install" data-confirm-class="btn-primary">
<div style="margin-bottom:0.4rem;"> <div style="margin-bottom:0.4rem;">
<label style="font-size:0.85rem;cursor:pointer;"> <label style="font-size:0.85rem;cursor:pointer;">
<input type="checkbox" id="select-all-child-{{ child.id }}" onchange="toggleAllChild{{ child.id }}(this)"> Select all <input type="checkbox" id="select-all-child-{{ child.id }}" onchange="toggleAllChild{{ child.id }}(this)"> Select all
@@ -236,6 +238,8 @@
<div class="card"> <div class="card">
<h2>Charge All Installed Batteries</h2> <h2>Charge All Installed Batteries</h2>
<form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}" <form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}"
data-confirm="Log a charge for all installed batteries in {{ device.name }}?"
data-confirm-ok="Log Charge" data-confirm-class="btn-primary"
style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;"> style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
<div class="form-group" style="margin:0;flex:1;min-width:140px;"> <div class="form-group" style="margin:0;flex:1;min-width:140px;">
<label>Date</label> <label>Date</label>
@@ -393,6 +397,8 @@ function addInstallRow() {
<div class="card"> <div class="card">
<h2>Charge All Installed Batteries</h2> <h2>Charge All Installed Batteries</h2>
<form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}" <form method="post" action="{{ url_for('device_charge_all', device_id=device.id) }}"
data-confirm="Log a charge for all installed batteries in {{ device.name }}?"
data-confirm-ok="Log Charge" data-confirm-class="btn-primary"
style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;"> style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
<div class="form-group" style="margin:0;flex:1;min-width:140px;"> <div class="form-group" style="margin:0;flex:1;min-width:140px;">
<label>Date</label> <label>Date</label>
@@ -418,7 +424,9 @@ function addInstallRow() {
<div class="card"> <div class="card">
<h2>Install Specific Batteries{% if device.battery_size %} <small class="text-muted" style="font-weight:normal;font-size:0.8rem;">({{ device.battery_size }} only)</small>{% endif %}</h2> <h2>Install Specific Batteries{% if device.battery_size %} <small class="text-muted" style="font-weight:normal;font-size:0.8rem;">({{ device.battery_size }} only)</small>{% endif %}</h2>
{% if available_batteries %} {% if available_batteries %}
<form method="post" action="{{ url_for('device_install_batch', device_id=device.id) }}"> <form method="post" action="{{ url_for('device_install_batch', device_id=device.id) }}"
data-confirm="Install the selected batteries into {{ device.name }}?"
data-confirm-ok="Install" data-confirm-class="btn-primary">
<div style="margin-bottom:0.5rem;"> <div style="margin-bottom:0.5rem;">
<label style="font-size:0.85rem;cursor:pointer;"> <label style="font-size:0.85rem;cursor:pointer;">
<input type="checkbox" id="select-all-avail" onchange="toggleAllAvail(this)"> Select all <input type="checkbox" id="select-all-avail" onchange="toggleAllAvail(this)"> Select all
+43 -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;">
@@ -69,9 +72,6 @@
data-name="{{ d.name|lower }}"> data-name="{{ d.name|lower }}">
<td data-label="Device"> <td data-label="Device">
<a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a> <a href="{{ url_for('device_detail', device_id=d.id) }}"><strong>{{ d.name }}</strong></a>
{% if d.has_children() %}
<br><small class="text-muted">{{ d.children|length }} sub-component{{ 's' if d.children|length != 1 }}</small>
{% endif %}
</td> </td>
<td data-label="Type">{{ d.device_type or '—' }}</td> <td data-label="Type">{{ d.device_type or '—' }}</td>
<td data-label="Size">{{ d.battery_size or '—' }}</td> <td data-label="Size">{{ d.battery_size or '—' }}</td>
@@ -110,6 +110,46 @@
</form> </form>
</td> </td>
</tr> </tr>
{% for child in d.children %}
{% set c_installed = child.installed_count() %}
{% if c_installed == 0 or child.battery_slots == 0 %}{% set c_fill = 'empty' %}
{% elif c_installed >= child.battery_slots %}{% set c_fill = 'full' %}
{% else %}{% set c_fill = 'partial' %}{% endif %}
<tr data-type="{{ d.device_type or '' }}"
data-battery-size="{{ child.battery_size or '' }}"
data-location="{{ d.location or '' }}"
data-fill="{{ c_fill }}"
data-name="{{ d.name|lower }} {{ child.name|lower }}">
<td data-label="Device" style="padding-left:1.75rem;">
<span class="text-muted"></span>
<a href="{{ url_for('device_detail', device_id=child.id) }}">{{ child.name }}</a>
</td>
<td data-label="Type" class="text-muted">{{ d.device_type or '—' }}</td>
<td data-label="Size">{{ child.battery_size or '—' }}</td>
<td data-label="Location" class="text-muted">{{ d.location or '—' }}</td>
<td data-label="Slots">{{ child.battery_slots }}</td>
<td data-label="Installed">
{{ c_installed }} / {{ child.battery_slots }}
{% if child.battery_slots > 0 and c_installed >= child.battery_slots %}
<span class="badge badge-retired">Full</span>
{% endif %}
</td>
<td data-label="Brands">
{% set c_brands = child.installed_brands() %}
{% if c_brands %}
{{ c_brands|join(', ') }}
{% if child.has_mixed_brands() %}
<span class="badge badge-warning">⚠ 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('device_detail', device_id=child.id) }}">View</a>
</td>
</tr>
{% endfor %}
{% else %} {% else %}
<tr><td colspan="8" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr> <tr><td colspan="8" class="text-muted" style="text-align:center;padding:1rem;">No devices yet. <a href="{{ url_for('device_add') }}">Add one.</a></td></tr>
{% endfor %} {% endfor %}
+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>
+287 -12
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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -117,6 +153,26 @@ def test_edit_notes(seeded_client):
# Battery — assign (per-battery, kept for special cases) # Battery — assign (per-battery, kept for special cases)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_edit_details_percentage_out_of_range_rejected(seeded_client):
for bad in ("150", "-5"):
resp = seeded_client.post("/battery/1/edit-details",
data={"battery_percentage": bad},
follow_redirects=True)
assert b"between 0 and 100" in resp.data
data = _json.loads(seeded_client.get("/export/all.json").data)
bat = next(b for b in data["batteries"] if b["id"] == 1)
assert bat["battery_percentage"] is None
assert data["pct_logs"] == []
def test_edit_details_percentage_valid(seeded_client):
seeded_client.post("/battery/1/edit-details", data={"battery_percentage": "100"})
data = _json.loads(seeded_client.get("/export/all.json").data)
bat = next(b for b in data["batteries"] if b["id"] == 1)
assert bat["battery_percentage"] == 100
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):
resp = seeded_client.post("/battery/1/assign", resp = seeded_client.post("/battery/1/assign",
data={"device_id": "1"}, data={"device_id": "1"},
@@ -168,6 +224,24 @@ def test_unassign_battery(seeded_client):
assert b"available" in resp2.data.lower() assert b"available" in resp2.data.lower()
def test_unassign_next_honors_local_path(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/battery/1/unassign", data={"next": "/device/1"})
assert get_location(resp) == "/device/1"
def test_unassign_next_rejects_external_redirect(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"})
assert resp.headers["Location"] == "/battery/"
def test_unassign_all_next_rejects_external_redirect(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/device/1/unassign-all", data={"next": "//evil.com"})
assert resp.headers["Location"] == "/device/"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — retire # Battery — retire
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -626,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):
@@ -703,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
@@ -826,6 +897,76 @@ def test_full_roundtrip_export_import(client):
assert b"Import Results" in resp.data assert b"Import Results" in resp.data
def test_export_json_includes_parent_fields(client):
_setup_rc_car(client)
data = _json.loads(client.get("/export/all.json").data)
devs = {d["name"]: d for d in data["devices"]}
assert devs["RC Car Set"]["parent_id"] is None
assert devs["Remote"]["parent_id"] == devs["RC Car Set"]["id"]
assert devs["Remote"]["parent_name"] == "RC Car Set"
def test_import_rebuilds_hierarchy(client):
payload = _make_import_payload(devices=[
_dev(10, "Hub", battery_slots=0, battery_size=None),
_dev(11, "Probe", parent_id=10),
])
resp = _post_import(client, payload)
assert resp.status_code == 200
data = _json.loads(client.get("/export/all.json").data)
devs = {d["name"]: d for d in data["devices"]}
assert devs["Probe"]["parent_id"] == devs["Hub"]["id"]
assert devs["Probe"]["parent_name"] == "Hub"
# parent's 0 slots survive the import (previously coerced to 1)
assert devs["Hub"]["battery_slots"] == 0
def test_import_child_listed_before_parent(client):
payload = _make_import_payload(devices=[
_dev(11, "Probe", parent_id=10),
_dev(10, "Hub", battery_slots=0, battery_size=None),
])
resp = _post_import(client, payload)
assert resp.status_code == 200
data = _json.loads(client.get("/export/all.json").data)
devs = {d["name"]: d for d in data["devices"]}
assert devs["Probe"]["parent_id"] == devs["Hub"]["id"]
def test_import_subcomponent_missing_parent_becomes_top_level(client):
payload = _make_import_payload(devices=[_dev(11, "Probe", parent_id=99)])
resp = _post_import(client, payload)
assert resp.status_code == 200
data = _json.loads(client.get("/export/all.json").data)
devs = {d["name"]: d for d in data["devices"]}
assert devs["Probe"]["parent_id"] is None
def test_import_empty_battery_size_stored_as_null(client):
payload = _make_import_payload(devices=[_dev(10, "NoSize", battery_size="")])
resp = _post_import(client, payload)
assert resp.status_code == 200
data = _json.loads(client.get("/export/all.json").data)
devs = {d["name"]: d for d in data["devices"]}
assert devs["NoSize"]["battery_size"] is None
def test_roundtrip_import_preserves_hierarchy(client):
_setup_rc_car(client)
data = _json.loads(client.get("/export/all.json").data)
# rename everything so the import creates rows instead of skipping
for d in data["devices"]:
d["name"] = d["name"] + " v2"
buf = io.BytesIO(_json.dumps(data).encode())
resp = client.post("/import", data={"file": (buf, "export.json")},
content_type="multipart/form-data")
assert resp.status_code == 200
data2 = _json.loads(client.get("/export/all.json").data)
devs = {d["name"]: d for d in data2["devices"]}
assert devs["Remote v2"]["parent_id"] == devs["RC Car Set v2"]["id"]
assert devs["Car v2"]["parent_id"] == devs["RC Car Set v2"]["id"]
def test_device_detail_unassign_all(seeded_client): def test_device_detail_unassign_all(seeded_client):
client = seeded_client client = seeded_client
# install battery 1 into device 1 (2-slot AA device) # install battery 1 into device 1 (2-slot AA device)
@@ -911,6 +1052,19 @@ def test_add_subcomponent(client):
assert b"Remote" in resp.data assert b"Remote" in resp.data
def test_device_with_children_cannot_become_subcomponent(client):
_setup_rc_car(client)
# Garage is parent-eligible (top-level, 0 slots, no size)
client.post("/device/add", data={"name": "Garage", "battery_slots": "0", "battery_size": ""})
resp = client.post("/device/1/edit",
data={"name": "RC Car Set", "battery_slots": "0", "parent_id": "4"},
follow_redirects=True)
assert b"cannot itself become a sub-component" in resp.data
data = _json.loads(client.get("/export/all.json").data)
devs = {d["name"]: d for d in data["devices"]}
assert devs["RC Car Set"]["parent_id"] is None
def test_subcomponent_prevents_deep_nesting(client): def test_subcomponent_prevents_deep_nesting(client):
_setup_rc_car(client) _setup_rc_car(client)
# id=2 is "Remote", which already has parent_id=1 # id=2 is "Remote", which already has parent_id=1
@@ -933,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
@@ -995,13 +1149,24 @@ def _setup_parent_with_two_children(client):
}, follow_redirects=True) }, follow_redirects=True)
def test_device_list_hides_subcomponents(client): def test_device_list_nests_subcomponents(client):
_setup_parent_with_two_children(client) _setup_parent_with_two_children(client)
resp = client.get("/device/") resp = client.get("/device/")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Hub" in resp.data assert b"Hub" in resp.data
assert b"Sensor A" not in resp.data # sub-components render as indented child rows under the parent
assert b"Sensor B" not in resp.data assert b"Sensor A" in resp.data
assert b"Sensor B" in resp.data
assert "".encode() in resp.data
# parent name is searchable on child rows
assert b'data-name="hub sensor a"' in resp.data
def test_assign_page_shows_parent_prefix(client):
_setup_rc_car(client)
resp = client.get("/battery/1/assign")
assert resp.status_code == 200
assert b"RC Car Set /" in resp.data
def test_subcomponent_type_location_null(client): def test_subcomponent_type_location_null(client):
@@ -1095,3 +1260,113 @@ def test_device_charge_all_parent_no_installed(client):
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert b"No installed batteries" in resp.data assert b"No installed batteries" in resp.data
# ------------------------------------------------------------------ #
# Battery — unretire
# ------------------------------------------------------------------ #
def test_unretire_battery(seeded_client):
# id=3 is retired by fixture
resp = seeded_client.post("/battery/3/unretire", follow_redirects=True)
assert resp.status_code == 200
assert b"now available again" in resp.data
assert b"available" in seeded_client.get("/battery/3").data.lower()
def test_unretire_not_retired(seeded_client):
resp = seeded_client.post("/battery/1/unretire", follow_redirects=True)
assert b"is not retired" in resp.data
# ------------------------------------------------------------------ #
# Logbook
# ------------------------------------------------------------------ #
def test_battery_logbook_add_and_delete(seeded_client):
resp = seeded_client.post("/battery/1/logbook/add",
data={"body": "Swapped terminals"}, follow_redirects=True)
assert b"Swapped terminals" in resp.data
resp = seeded_client.post("/battery/1/logbook/1/delete", follow_redirects=True)
assert b"Logbook entry deleted" in resp.data
assert b"Swapped terminals" not in seeded_client.get("/battery/1").data
def test_battery_logbook_add_empty_body(seeded_client):
resp = seeded_client.post("/battery/1/logbook/add",
data={"body": " "}, follow_redirects=True)
assert b"Entry text is required" in resp.data
def test_device_logbook_add_and_delete(seeded_client):
resp = seeded_client.post("/device/1/logbook/add",
data={"body": "Cleaned contacts"}, follow_redirects=True)
assert b"Cleaned contacts" in resp.data
resp = seeded_client.post("/device/1/logbook/1/delete", follow_redirects=True)
assert b"Logbook entry deleted" in resp.data
assert b"Cleaned contacts" not in seeded_client.get("/device/1").data
def test_device_logbook_add_empty_body(seeded_client):
resp = seeded_client.post("/device/1/logbook/add",
data={"body": ""}, follow_redirects=True)
assert b"Entry text is required" in resp.data
# ------------------------------------------------------------------ #
# Charge log — delete
# ------------------------------------------------------------------ #
def test_charge_log_delete_reverts_cycles(seeded_client):
seeded_client.post("/battery/1/charge-log/add",
data={"charged_date": "2026-06-01", "increment_cycles": "1"})
data = _json.loads(seeded_client.get("/export/all.json").data)
assert next(b for b in data["batteries"] if b["id"] == 1)["charge_cycles"] == 1
log_id = data["charge_logs"][0]["id"]
resp = seeded_client.post(f"/battery/1/charge-log/{log_id}/delete",
follow_redirects=True)
assert b"Charge log entry deleted" in resp.data
data = _json.loads(seeded_client.get("/export/all.json").data)
assert next(b for b in data["batteries"] if b["id"] == 1)["charge_cycles"] == 0
assert data["charge_logs"] == []
# ------------------------------------------------------------------ #
# CSV exports
# ------------------------------------------------------------------ #
def test_csv_export_routes(seeded_client):
expectations = {
"/export/batteries.csv": b"label",
"/export/devices.csv": b"parent_id,parent_name",
"/export/charge-logs.csv": b"charged_date",
"/export/capacity-tests.csv": b"tested_capacity_mah",
"/export/pct-logs.csv": b"percentage",
}
for url, marker in expectations.items():
resp = seeded_client.get(url)
assert resp.status_code == 200, url
assert "text/csv" in resp.content_type, url
assert marker in resp.data, url
resp = seeded_client.get("/export/csv.zip")
assert resp.status_code == 200
assert "zip" in resp.content_type
# ------------------------------------------------------------------ #
# Sub-component edit — type/location stay NULL
# ------------------------------------------------------------------ #
def test_subcomponent_edit_resave_keeps_nulls(client):
_setup_rc_car(client)
# device 2 = Remote, sub-component of 1; re-save smuggling in type/location
resp = client.post("/device/2/edit", data={
"name": "Remote", "battery_slots": "2", "parent_id": "1",
"device_type": "Toy", "location": "Garage", "battery_size": "AA",
}, follow_redirects=True)
assert resp.status_code == 200
data = _json.loads(client.get("/export/all.json").data)
rem = next(d for d in data["devices"] if d["name"] == "Remote")
assert rem["parent_id"] is not None
assert rem["device_type"] is None
assert rem["location"] is None
File diff suppressed because it is too large Load Diff
+60 -3
View File
@@ -182,6 +182,62 @@ def test_poll_updates_installed_batteries(ha_app, ha_client_f):
mock_ha.get_state.assert_called_once_with("sensor.dev_a_battery") mock_ha.get_state.assert_called_once_with("sensor.dev_a_battery")
def test_poll_updates_batteries_in_subcomponents(ha_app, ha_client_f):
"""Batteries installed in sub-components are updated via the parent's HA entity."""
ha_client_f.post("/device/add", data={"name": "RC Set", "battery_slots": "0", "battery_size": ""})
ha_client_f.post("/device/add", data={"name": "Remote", "battery_slots": "2",
"battery_size": "AA", "parent_id": "1"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1", "size": "AA"})
ha_client_f.post("/battery/1/assign", data={"device_id": "2"})
ha_client_f.post("/device/1/edit", data={
"name": "RC Set", "battery_slots": "0", "battery_size": "",
"ha_entity_id": "sensor.rc_set_battery"
})
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
from models import Battery, BatteryPctLog
mock_ha = MagicMock(spec=HomeAssistantClient)
mock_ha.enabled = True
mock_ha.get_state.return_value = 37
Session = _make_session_factory(ha_app)
HaPoller(mock_ha, Session, interval=300)._poll_once()
s = Session()
b = s.get(Battery, 1)
assert b.battery_percentage == 37
logs = s.query(BatteryPctLog).filter_by(battery_id=1, source="poll").all()
assert len(logs) == 1 and logs[0].percentage == 37
s.close()
def test_poll_clamps_out_of_range_percentage(ha_app, ha_client_f):
"""HA sensors occasionally report out-of-range values; the poller clamps to 0-100."""
ha_client_f.post("/device/add", data={"name": "Dev E", "battery_slots": "1", "battery_size": "AA"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev E", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.dev_e"
})
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
from models import Battery
mock_ha = MagicMock(spec=HomeAssistantClient)
mock_ha.enabled = True
mock_ha.get_state.return_value = 150
Session = _make_session_factory(ha_app)
HaPoller(mock_ha, Session, interval=300)._poll_once()
s = Session()
assert s.get(Battery, 1).battery_percentage == 100
s.close()
def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f): def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f):
"""Batteries that are available (not installed) are not updated by the poller.""" """Batteries that are available (not installed) are not updated by the poller."""
ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1", "battery_size": "AA"}) ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1", "battery_size": "AA"})
@@ -258,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
@@ -295,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)
@@ -428,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