Compare commits

...
21 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
iterminate ab1340998e Charge all batteries across sub-components from parent device 2026-06-09 21:42:08 -05:00
iterminate 27ed4c9420 Redirect to device detail after create; sub-component install UI on parent detail 2026-06-09 21:18:22 -05:00
iterminate 23eeeafff7 Scope device uniqueness per parent, null type/location for sub-components, hide sub-components from list, flat battery summary for parent devices 2026-06-09 19:26:31 -05:00
25 changed files with 3495 additions and 603 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) |
--- ---
+197 -100
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).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})
@@ -733,8 +782,11 @@ def create_app(config_object="config"):
form_device_type=request.form.get("device_type", ""), form_device_type=request.form.get("device_type", ""),
prefill_parent=parent_device), 400 prefill_parent=parent_device), 400
if db.query(Device).filter_by(name=name).first(): parent_key_val = parent_device.id if parent_device else -1
flash(f"A device named '{name}' already exists.", "error") if db.query(Device).filter(
Device.parent_key == parent_key_val, Device.name == name
).first():
flash(f"A device named '{name}' already exists here.", "error")
return render_template("device_add.html", return render_template("device_add.html",
device_types=device_types, device_types=device_types,
device_locations=device_locations, device_locations=device_locations,
@@ -744,23 +796,24 @@ def create_app(config_object="config"):
form_device_type=request.form.get("device_type", ""), form_device_type=request.form.get("device_type", ""),
prefill_parent=parent_device), 400 prefill_parent=parent_device), 400
is_sub = parent_device is not None
device = Device(name=name, battery_slots=slots, notes=notes, device = Device(name=name, battery_slots=slots, notes=notes,
device_type=device_type, battery_size=battery_size, device_type=None if is_sub else device_type,
location=location, battery_size=battery_size,
parent_id=parent_device.id if parent_device else None) location=None if is_sub else location,
parent_id=parent_device.id if parent_device else None,
parent_key=parent_key_val)
db.add(device) db.add(device)
db.commit() db.commit()
flash(f"Device '{name}' added.", "success") flash(f"Device '{name}' added.", "success")
if parent_device: if parent_device:
return redirect(url_for("device_detail", device_id=parent_device.id)) return redirect(url_for("device_detail", device_id=parent_device.id))
return redirect(url_for("device_list")) return redirect(url_for("device_detail", device_id=device.id))
return render_template("device_add.html", device_types=device_types, return render_template("device_add.html", device_types=device_types,
device_locations=device_locations, device_locations=device_locations,
device_battery_sizes=device_battery_sizes, device_battery_sizes=device_battery_sizes,
prefill_parent=prefill_parent, prefill_parent=prefill_parent)
form_device_type=prefill_parent.device_type if prefill_parent else None,
form_location=prefill_parent.location if prefill_parent else None)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — detail # Devices — detail
@@ -772,16 +825,13 @@ 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})
@@ -791,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,
@@ -804,6 +855,17 @@ def create_app(config_object="config"):
changed = True changed = True
if changed: if changed:
db.commit() db.commit()
flat_installed = []
children_avail = {}
if device.has_children():
for child in device.children:
for b in child.batteries:
if b.status == "installed":
flat_installed.append((b, child.name, child.id))
avail_q = _filter_compatible(
db.query(Battery).filter_by(status="available"), child.battery_size
)
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,
device_types=device_types, device_types=device_types,
@@ -812,7 +874,9 @@ def create_app(config_object="config"):
device_list_all=all_devices, device_list_all=all_devices,
ha_enabled=ha_client.enabled, ha_enabled=ha_client.enabled,
ha_live_pct=ha_live_pct, ha_live_pct=ha_live_pct,
logbook_entries=device.logbook_entries) logbook_entries=device.logbook_entries,
flat_installed=flat_installed,
children_avail=children_avail)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — edit # Devices — edit
@@ -840,13 +904,20 @@ def create_app(config_object="config"):
except ValueError: except ValueError:
flash("Battery slots must be a positive integer.", "error") flash("Battery slots must be a positive integer.", "error")
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
existing = db.query(Device).filter_by(name=name).first() # Determine new parent key for scoped uniqueness check
if existing and existing.id != device_id: parent_id_raw = request.form.get("parent_id", "").strip()
flash(f"A device named '{name}' already exists.", "error") new_parent_key_val = int(parent_id_raw) if (parent_id_raw and parent_id_raw.isdigit()) else -1
existing = db.query(Device).filter(
Device.parent_key == new_parent_key_val,
Device.name == name,
Device.id != device_id,
).first()
if existing:
flash(f"A device named '{name}' already exists here.", "error")
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
# Validate parent_id change # Validate parent_id change
parent_id_raw = request.form.get("parent_id", "").strip()
if parent_id_raw and parent_id_raw.isdigit(): if parent_id_raw and parent_id_raw.isdigit():
new_parent = db.get(Device, int(parent_id_raw)) new_parent = db.get(Device, int(parent_id_raw))
if not new_parent: if not new_parent:
@@ -855,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))
@@ -866,12 +940,19 @@ 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))
device.parent_id = new_parent.id device.parent_id = new_parent.id
device.parent_key = new_parent.id
else: else:
device.parent_id = None device.parent_id = None
device.parent_key = -1
device.name = name device.name = name
device.notes = notes device.notes = notes
if device.is_subcomponent():
device.device_type = None
device.location = None
else:
device.device_type = device_type device.device_type = device_type
device.location = request.form.get("location", "").strip() or None
if device.has_children(): if device.has_children():
device.battery_slots = 0 device.battery_slots = 0
device.battery_size = None device.battery_size = None
@@ -880,7 +961,6 @@ def create_app(config_object="config"):
new_battery_size = request.form.get("battery_size", "").strip() or None new_battery_size = request.form.get("battery_size", "").strip() or None
if new_battery_size is not None: if new_battery_size is not None:
device.battery_size = new_battery_size device.battery_size = new_battery_size
device.location = request.form.get("location", "").strip() or None
device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None
db.commit() db.commit()
flash("Device updated.", "success") flash("Device updated.", "success")
@@ -928,10 +1008,9 @@ 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:
@@ -944,10 +1023,9 @@ 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:
@@ -1050,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
@@ -1107,7 +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
installed = [b for b in device.batteries if b.status == "installed"] installed = device.installed_batteries()
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))
@@ -1148,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():
@@ -1160,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():
@@ -1180,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")
@@ -1251,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,
@@ -1267,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",
@@ -1321,26 +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
existing = db.query(Device).filter_by(name=name).first() parent_key = new_parent_id if new_parent_id is not None else -1
existing = db.query(Device).filter(
Device.parent_key == parent_key, Device.name == name
).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_id = new_parent_id,
parent_key = parent_key,
) )
db.add(new_dev) db.add(new_dev)
db.flush() db.flush()
@@ -1348,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,
+27 -3
View File
@@ -1,6 +1,5 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table, UniqueConstraint
from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base() Base = declarative_base()
@@ -25,7 +24,7 @@ class Device(Base):
__tablename__ = "device" __tablename__ = "device"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True) name = Column(String(100), nullable=False)
battery_slots = Column(Integer, nullable=False, default=1) battery_slots = Column(Integer, nullable=False, default=1)
device_type = Column(String(50), nullable=True) device_type = Column(String(50), nullable=True)
battery_size = Column(String(20), nullable=True) # AA, AAA, 9V, CR2032 …; null for parent-only devices battery_size = Column(String(20), nullable=True) # AA, AAA, 9V, CR2032 …; null for parent-only devices
@@ -33,6 +32,11 @@ class Device(Base):
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery" ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery"
parent_id = Column(Integer, ForeignKey("device.id", ondelete="SET NULL"), nullable=True) parent_id = Column(Integer, ForeignKey("device.id", ondelete="SET NULL"), nullable=True)
parent_key = Column(Integer, nullable=False, default=-1) # -1 for top-level, parent_id for sub-components
__table_args__ = (
UniqueConstraint("parent_key", "name", name="uq_device_parent_key_name"),
)
batteries = relationship("Battery", back_populates="device") batteries = relationship("Battery", back_populates="device")
logbook_entries = relationship( logbook_entries = relationship(
@@ -71,6 +75,26 @@ 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):
if self.has_children():
return sum(c.installed_count() for c in self.children)
return self.installed_count()
def effective_slots(self):
if self.has_children():
return sum(c.battery_slots for c in self.children)
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."""
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
sbin/migrate_parent_key.py
==========================
Adds parent_key column, changes uniqueness from UNIQUE(name) to
UNIQUE(parent_key, name), and clears device_type/location for sub-components.
SQLite: recreates the device table (required to change constraints).
MariaDB: uses ALTER TABLE. Check the index name with:
SHOW INDEX FROM device WHERE Column_name = 'name';
Usage:
python sbin/migrate_parent_key.py # SQLite (batteries.db)
MARIADB_URL='mysql+...' python sbin/migrate_parent_key.py
"""
import os
import shutil
from datetime import date
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def snapshot(src: Path) -> None:
dst = src.parent / f"{src.name}.{date.today().isoformat()}.snapshot"
shutil.copy2(src, dst)
print(f"Snapshot: {dst.name}")
def migrate_sqlite() -> None:
import sqlite3
db_path = REPO_ROOT / "batteries.db"
if not db_path.exists():
print("batteries.db not found — nothing to migrate.")
return
snapshot(db_path)
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys = OFF")
# Check if parent_key already exists (idempotency)
cols = {row[1] for row in conn.execute("PRAGMA table_info(device)")}
if "parent_key" in cols:
print("parent_key already present — running data-only fixup.")
conn.execute("UPDATE device SET parent_key = COALESCE(parent_id, -1)")
conn.execute("UPDATE device SET device_type = NULL, location = NULL WHERE parent_id IS NOT NULL")
conn.commit()
conn.close()
print("Done.")
return
conn.execute("ALTER TABLE device RENAME TO device_old")
conn.execute("""
CREATE TABLE device (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
battery_slots INTEGER NOT NULL DEFAULT 1,
device_type VARCHAR(50),
battery_size VARCHAR(20),
location VARCHAR(100),
notes TEXT,
ha_entity_id VARCHAR(100),
parent_id INTEGER REFERENCES device(id) ON DELETE SET NULL,
parent_key INTEGER NOT NULL DEFAULT -1,
CONSTRAINT uq_device_parent_key_name UNIQUE (parent_key, name)
)
""")
conn.execute("""
INSERT INTO device
(id, name, battery_slots, device_type, battery_size, location,
notes, ha_entity_id, parent_id, parent_key)
SELECT
id, name, battery_slots,
CASE WHEN parent_id IS NOT NULL THEN NULL ELSE device_type END,
battery_size,
CASE WHEN parent_id IS NOT NULL THEN NULL ELSE location END,
notes, ha_entity_id, parent_id,
COALESCE(parent_id, -1)
FROM device_old
""")
conn.execute("DROP TABLE device_old")
conn.execute("PRAGMA foreign_keys = ON")
conn.commit()
count = conn.execute("SELECT COUNT(*) FROM device").fetchone()[0]
print(f"SQLite migration complete — {count} device(s) migrated.")
conn.close()
def migrate_mariadb(url: str) -> None:
from sqlalchemy import create_engine, text
engine = create_engine(url)
with engine.connect() as conn:
# Add column (skip if already present)
try:
conn.execute(text(
"ALTER TABLE device ADD COLUMN parent_key INT NOT NULL DEFAULT -1"
))
except Exception:
print("parent_key column already exists — skipping ADD COLUMN.")
conn.execute(text("UPDATE device SET parent_key = COALESCE(parent_id, -1)"))
conn.execute(text(
"UPDATE device SET device_type = NULL, location = NULL WHERE parent_id IS NOT NULL"
))
# Drop old UNIQUE index on name — check actual name first:
# SHOW INDEX FROM device WHERE Column_name = 'name';
# Common names: 'name' or 'ix_device_name'
for idx_name in ("name", "ix_device_name"):
try:
conn.execute(text(f"ALTER TABLE device DROP INDEX `{idx_name}`"))
print(f"Dropped index '{idx_name}'.")
break
except Exception:
continue
conn.execute(text(
"ALTER TABLE device ADD CONSTRAINT uq_device_parent_key_name "
"UNIQUE (parent_key, name)"
))
conn.commit()
print("MariaDB migration complete.")
if __name__ == "__main__":
mariadb_url = os.environ.get("MARIADB_URL", "").strip()
if mariadb_url:
migrate_mariadb(mariadb_url)
else:
migrate_sqlite()
+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 %}
+2 -12
View File
@@ -56,6 +56,7 @@
</div> </div>
</div><!-- #battery-fields --> </div><!-- #battery-fields -->
{% if not prefill_parent %}
<div class="form-group"> <div class="form-group">
<label>Type</label> <label>Type</label>
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %} {% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
@@ -94,6 +95,7 @@
placeholder="e.g. Living Room, Bedroom" placeholder="e.g. Living Room, Bedroom"
style="display:none;margin-top:0.4rem;"> style="display:none;margin-top:0.4rem;">
</div> </div>
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="notes">Notes</label> <label for="notes">Notes</label>
@@ -127,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 %}
+145 -22
View File
@@ -63,6 +63,20 @@
<td style="border:none;">{{ device.location }}</td> <td style="border:none;">{{ device.location }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if device.is_subcomponent() and device.parent %}
{% if device.parent.device_type %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Type</td>
<td style="border:none;">{{ device.parent.device_type }} <span class="text-muted" style="font-size:0.85em;">(inherited)</span></td>
</tr>
{% endif %}
{% if device.parent.location %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Location</td>
<td style="border:none;">{{ device.parent.location }} <span class="text-muted" style="font-size:0.85em;">(inherited)</span></td>
</tr>
{% endif %}
{% endif %}
{% if device.notes %} {% if device.notes %}
<tr> <tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td> <td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
@@ -94,17 +108,85 @@
</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>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.75rem;"> <div style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem;">
{% for child in device.children %} {% for child in device.children %}
<a href="{{ url_for('device_detail', device_id=child.id) }}" {% set child_free = child.battery_slots - child.installed_count() %}
style="display:block;padding:0.6rem 0.9rem;border:1px solid var(--border);border-radius:6px;text-decoration:none;min-width:140px;background:var(--bg-card);"> {% set child_batteries = children_avail.get(child.id, []) %}
<strong>{{ child.name }}</strong><br> <details style="border:1px solid var(--border);border-radius:6px;background:var(--bg-card);">
<small class="text-muted">{{ child.installed_count() }}/{{ child.battery_slots }} slots &middot; {{ child.battery_size }}{% if child.ha_entity_id %} &middot; HA{% endif %}</small> <summary style="padding:0.6rem 0.9rem;cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;">
</a> <span>
<strong>{{ child.name }}</strong>
<small class="text-muted" style="margin-left:0.5rem;">{{ child.installed_count() }}/{{ child.battery_slots }} slots &middot; {{ child.battery_size }}{% if child.ha_entity_id %} &middot; HA{% endif %}</small>
</span>
<span style="display:flex;gap:0.5rem;align-items:center;">
{% if child_free > 0 and child_batteries %}<small class="text-muted">{{ child_batteries|length }} available</small>{% endif %}
<a href="{{ url_for('device_detail', device_id=child.id) }}" onclick="event.stopPropagation();" style="font-size:0.8rem;">View</a>
</span>
</summary>
<div style="padding:0.6rem 0.9rem;border-top:1px solid var(--border);">
{% if child_free <= 0 %}
<p class="text-muted" style="margin:0;">No free slots.</p>
{% elif not child_batteries %}
<p class="text-muted" style="margin:0;">No compatible batteries available.</p>
{% else %}
<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;">
<label style="font-size:0.85rem;cursor:pointer;">
<input type="checkbox" id="select-all-child-{{ child.id }}" onchange="toggleAllChild{{ child.id }}(this)"> Select all
</label>
</div>
<div style="max-height:180px;overflow-y:auto;border:1px solid #cbd5e1;border-radius:4px;padding:0.4rem 0.6rem;">
{% for b in child_batteries %}
<div>
<label style="font-size:0.9rem;cursor:pointer;">
<input type="checkbox" class="child-bat-{{ child.id }}" name="battery_ids" value="{{ b.id }}">
{{ b.label }} — {{ b.brand }}{% if b.battery_percentage is not none %} ({{ b.battery_percentage }}%){% endif %}
</label>
</div>
{% endfor %}
</div>
<button class="btn btn-sm btn-primary" type="submit" style="margin-top:0.5rem;">Install Selected</button>
</form>
<script>
(function() {
var FREE_{{ child.id }} = {{ child_free }};
var queue_{{ child.id }} = [];
document.querySelectorAll('.child-bat-{{ child.id }}').forEach(function(cb) {
cb.addEventListener('change', function() {
if (cb.checked) {
queue_{{ child.id }}.push(cb);
if (queue_{{ child.id }}.length > FREE_{{ child.id }}) {
queue_{{ child.id }}.shift().checked = false;
}
} else {
queue_{{ child.id }} = queue_{{ child.id }}.filter(function(c) { return c !== cb; });
}
document.getElementById('select-all-child-{{ child.id }}').checked = false;
});
});
window['toggleAllChild{{ child.id }}'] = function(masterCb) {
var all = Array.from(document.querySelectorAll('.child-bat-{{ child.id }}'));
if (masterCb.checked) {
queue_{{ child.id }} = [];
all.forEach(function(c) { c.checked = false; });
all.slice(0, FREE_{{ child.id }}).forEach(function(c) { c.checked = true; queue_{{ child.id }}.push(c); });
if (all.length > FREE_{{ child.id }}) masterCb.checked = false;
} else {
all.forEach(function(c) { c.checked = false; });
queue_{{ child.id }} = [];
}
};
})();
</script>
{% endif %}
</div>
</details>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@@ -116,24 +198,30 @@
{% endif %} {% endif %}
{% if device.has_children() %} {% if device.has_children() %}
<div class="card"> <div class="card" id="installed">
<h2>Batteries in Sub-components</h2> <h2>Installed Batteries</h2>
{% for child in device.children %} {% if flat_installed %}
{% set child_installed = child.batteries | selectattr('status', 'eq', 'installed') | list %}
<h3 style="margin:0.75rem 0 0.4rem;font-size:1rem;">
<a href="{{ url_for('device_detail', device_id=child.id) }}">{{ child.name }}</a>
<small class="text-muted" style="font-weight:normal;">&nbsp;{{ child_installed|length }}/{{ child.battery_slots }}</small>
</h3>
{% if child_installed %}
<div class="table-wrap"> <div class="table-wrap">
<table class="responsive-table"> <table class="responsive-table">
<thead><tr><th>Label</th><th>Brand</th>{% if ha_enabled %}<th>Bat %</th>{% endif %}<th>Last Charged</th></tr></thead> <thead><tr>
<th>Component</th><th>Label</th><th>Brand</th>
{% if ha_enabled %}<th>Bat %</th>{% endif %}
<th>Last Charged</th>
</tr></thead>
<tbody> <tbody>
{% for b in child_installed %} {% for b, comp_name, comp_id in flat_installed %}
<tr> <tr>
<td data-label="Component"><a href="{{ url_for('device_detail', device_id=comp_id) }}">{{ comp_name }}</a></td>
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td> <td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
<td data-label="Brand">{{ b.brand }}</td> <td data-label="Brand">{{ b.brand }}</td>
{% if ha_enabled %}<td data-label="Bat %">{% if b.battery_percentage is not none %}{{ b.battery_percentage }}%{% else %}—{% endif %}</td>{% endif %} {% if ha_enabled %}
<td data-label="Bat %">
{% if b.battery_percentage is not none %}
{% if b.battery_percentage < 20 %}<span class="badge badge-warning">⚠ {{ b.battery_percentage }}%</span>
{% else %}{{ b.battery_percentage }}%{% endif %}
{% else %}—{% endif %}
</td>
{% endif %}
<td data-label="Last Charged" class="text-muted">{{ b.charge_logs[-1].charged_date if b.charge_logs else '—' }}</td> <td data-label="Last Charged" class="text-muted">{{ b.charge_logs[-1].charged_date if b.charge_logs else '—' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -141,9 +229,36 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="text-muted" style="margin:0 0 0.5rem;">No batteries installed.</p> <p class="text-muted">No batteries installed across sub-components.</p>
{% endif %} {% endif %}
{% endfor %} </div>
{% endif %}
{% if device.has_children() and flat_installed %}
<div class="card">
<h2>Charge All Installed Batteries</h2>
<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;">
<div class="form-group" style="margin:0;flex:1;min-width:140px;">
<label>Date</label>
<input type="date" name="charged_date" required>
</div>
<div class="form-group" style="margin:0;align-self:flex-end;padding-bottom:1rem;">
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="increment_cycles" value="1" checked>
Increment charge cycles
</label>
</div>
<div class="form-group" style="margin:0;flex:2;min-width:160px;">
<label>Notes (optional)</label>
<input type="text" name="notes" placeholder="e.g. overnight charge">
</div>
<div style="padding-bottom:1rem;">
<button class="btn btn-primary" type="submit">Log Charge for All</button>
</div>
</form>
</div> </div>
{% endif %} {% endif %}
@@ -282,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>
@@ -307,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
@@ -407,6 +526,7 @@ function addInstallRow() {
<input type="number" id="edit-slots" name="battery_slots" value="{{ device.battery_slots }}" min="1" required> <input type="number" id="edit-slots" name="battery_slots" value="{{ device.battery_slots }}" min="1" required>
</div> </div>
{% endif %} {% endif %}
{% if not device.is_subcomponent() %}
<div class="form-group"> <div class="form-group">
<label>Type</label> <label>Type</label>
{% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %} {% set _preset_types = ['Remote Control','Game Controller','Flashlight','Lock','Sensor','Toy','Clock','Smoke Detector'] %}
@@ -427,6 +547,7 @@ function addInstallRow() {
placeholder="Enter device type" placeholder="Enter device type"
style="display:{% if device.device_type and device.device_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;"> style="display:{% if device.device_type and device.device_type not in _preset_types %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div> </div>
{% endif %}
{% if not device.has_children() %} {% if not device.has_children() %}
<div class="form-group"> <div class="form-group">
<label>Battery Size</label> <label>Battery Size</label>
@@ -452,6 +573,7 @@ function addInstallRow() {
style="display:{% if device.battery_size and device.battery_size not in _preset_sizes %}''{% else %}none{% endif %};margin-top:0.4rem;"> style="display:{% if device.battery_size and device.battery_size not in _preset_sizes %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div> </div>
{% endif %} {% endif %}
{% if not device.is_subcomponent() %}
<div class="form-group"> <div class="form-group">
<label>Location</label> <label>Location</label>
<select id="edit-location-select" onchange="editLocationSelectChanged(this)"> <select id="edit-location-select" onchange="editLocationSelectChanged(this)">
@@ -469,6 +591,7 @@ function addInstallRow() {
placeholder="e.g. Living Room, Bedroom" placeholder="e.g. Living Room, Bedroom"
style="display:{% if device.location and device.location not in device_locations|default([]) %}''{% else %}none{% endif %};margin-top:0.4rem;"> style="display:{% if device.location and device.location not in device_locations|default([]) %}''{% else %}none{% endif %};margin-top:0.4rem;">
</div> </div>
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="edit-notes">Notes</label> <label for="edit-notes">Notes</label>
+50 -11
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;">
@@ -57,9 +60,10 @@
</thead> </thead>
<tbody> <tbody>
{% for d in devices %} {% for d in devices %}
{% set installed = d.installed_count() %} {% set installed = d.effective_installed_count() %}
{% if installed == 0 %}{% set fill_state = 'empty' %} {% set total_slots = d.effective_slots() %}
{% elif installed >= d.battery_slots %}{% set fill_state = 'full' %} {% if installed == 0 or total_slots == 0 %}{% set fill_state = 'empty' %}
{% elif installed >= total_slots %}{% set fill_state = 'full' %}
{% else %}{% set fill_state = 'partial' %}{% endif %} {% else %}{% set fill_state = 'partial' %}{% endif %}
<tr data-type="{{ d.device_type or '' }}" <tr data-type="{{ d.device_type or '' }}"
data-battery-size="{{ d.battery_size or '' }}" data-battery-size="{{ d.battery_size or '' }}"
@@ -68,19 +72,14 @@
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.parent %}
<br><small class="text-muted"><a href="{{ url_for('device_detail', device_id=d.parent.id) }}">{{ d.parent.name }}</a></small>
{% elif 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>
<td data-label="Location">{{ d.location or '—' }}</td> <td data-label="Location">{{ d.location or '—' }}</td>
<td data-label="Slots">{{ d.battery_slots }}</td> <td data-label="Slots">{{ total_slots }}</td>
<td data-label="Installed"> <td data-label="Installed">
{{ installed }} / {{ d.battery_slots }} {{ installed }} / {{ total_slots }}
{% if installed >= d.battery_slots %} {% if total_slots > 0 and installed >= total_slots %}
<span class="badge badge-retired">Full</span> <span class="badge badge-retired">Full</span>
{% endif %} {% endif %}
</td> </td>
@@ -111,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>
+404 -9
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
@@ -975,3 +1129,244 @@ def test_subcomponent_ha_entity_id(client):
resp = client.get("/device/2") resp = client.get("/device/2")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Hub" in resp.data assert b"Hub" in resp.data
# ------------------------------------------------------------------ #
# Sub-component hierarchy — parent_key, null type/location, list hiding
# ------------------------------------------------------------------ #
def _setup_parent_with_two_children(client):
"""Hub (top-level, Sensor type, Living Room) with Sensor A and Sensor B."""
client.post("/device/add", data={
"name": "Hub", "battery_slots": "0", "battery_size": "",
"device_type": "Sensor", "location": "Living Room",
}, follow_redirects=True)
client.post("/device/add", data={
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
}, follow_redirects=True)
client.post("/device/add", data={
"name": "Sensor B", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
}, follow_redirects=True)
def test_device_list_nests_subcomponents(client):
_setup_parent_with_two_children(client)
resp = client.get("/device/")
assert resp.status_code == 200
assert b"Hub" in resp.data
# sub-components render as indented child rows under the parent
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):
_setup_parent_with_two_children(client)
# Sub-component detail shows inherited type/location from parent
resp = client.get("/device/2")
assert resp.status_code == 200
assert b"Sensor" in resp.data
assert b"Living Room" in resp.data
assert b"inherited" in resp.data
# Submitting type/location in edit for sub-component should have no effect
client.post("/device/2/edit", data={
"name": "Sensor A", "battery_slots": "1",
"device_type": "Remote Control", "location": "Bedroom",
"parent_id": "1", # keep sub-component relationship
})
resp2 = client.get("/device/2")
assert b"Remote Control" not in resp2.data
assert b"Bedroom" not in resp2.data
def test_subcomponent_name_unique_per_parent(client):
_setup_parent_with_two_children(client)
# Duplicate top-level name → 400
resp = client.post("/device/add", data={
"name": "Hub", "battery_slots": "0", "battery_size": "",
})
assert resp.status_code == 400
assert b"already exists" in resp.data
# Duplicate child name within same parent → 400
resp = client.post("/device/add", data={
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "1",
})
assert resp.status_code == 400
assert b"already exists" in resp.data
# Same child name under a DIFFERENT parent → success
client.post("/device/add", data={
"name": "Hub 2", "battery_slots": "0", "battery_size": "",
}) # id=4
resp = client.post("/device/add", data={
"name": "Sensor A", "battery_slots": "1", "battery_size": "AA", "parent_id": "4",
}, follow_redirects=True)
assert resp.status_code == 200
def test_parent_battery_summary_flat(client):
_setup_parent_with_two_children(client)
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"}) # ids 1, 2
client.post("/device/2/install-one", data={"battery_id": "1"})
client.post("/device/3/install-one", data={"battery_id": "2"})
resp = client.get("/device/1")
assert resp.status_code == 200
# Flat table has Component column heading
assert b"Component" in resp.data
# Both batteries appear
assert b"Eneloop 001" in resp.data
assert b"Eneloop 002" in resp.data
# Both child names linked in the component column
assert b"Sensor A" in resp.data
assert b"Sensor B" in resp.data
# Flat table heading appears once (not duplicated per sub-component)
assert resp.data.count(b"<h2>Installed Batteries</h2>") == 1
def test_device_charge_all_parent(client):
_setup_parent_with_two_children(client)
client.post("/battery/add", data={"brand": "Eneloop", "label": "E001", "size": "AA"})
client.post("/battery/add", data={"brand": "Eneloop", "label": "E002", "size": "AA"})
client.post("/device/2/install-one", data={"battery_id": "1"})
client.post("/device/3/install-one", data={"battery_id": "2"})
resp = client.post(
"/device/1/charge-all",
data={"charged_date": "2025-06-01", "increment_cycles": "1"},
follow_redirects=True,
)
assert resp.status_code == 200
assert b"Logged charge for 2 batteri" in resp.data
assert b"+cycles" in resp.data
def test_device_charge_all_parent_no_installed(client):
_setup_parent_with_two_children(client)
resp = client.post(
"/device/1/charge-all",
data={"charged_date": "2025-06-01"},
follow_redirects=True,
)
assert resp.status_code == 200
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