Compare commits

...
2 Commits
13 changed files with 904 additions and 65 deletions
+66 -35
View File
@@ -97,7 +97,7 @@ def create_app(config_object="config"):
poller.start() poller.start()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Dashboard # Home / Battery list
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@app.route("/sw.js") @app.route("/sw.js")
@@ -106,26 +106,14 @@ def create_app(config_object="config"):
mimetype="application/javascript") mimetype="application/javascript")
@app.route("/") @app.route("/")
def dashboard(): def home():
batteries = db.query(Battery).order_by(Battery.label).all() batteries = db.query(Battery).all()
storage_locations = [
r[0] for r in db.query(Battery.storage_location)
.filter(Battery.storage_location.isnot(None))
.distinct().order_by(Battery.storage_location).all()
]
devices = db.query(Device).order_by(Device.name).all()
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots and not d.has_children()]
today = date.today() today = date.today()
one_year_ago = (today - timedelta(days=365)).isoformat() one_year_ago = (today - timedelta(days=365)).isoformat()
total_charges = db.query(func.count(ChargeLog.id)).scalar() or 0 total_charges = db.query(func.count(ChargeLog.id)).scalar() or 0
charges_last_year = (db.query(func.count(ChargeLog.id)) charges_last_year = (db.query(func.count(ChargeLog.id))
.filter(ChargeLog.charged_date >= one_year_ago) .filter(ChargeLog.charged_date >= one_year_ago)
.scalar()) or 0 .scalar()) or 0
last_charged_map = {
r[0]: r[1]
for r in db.query(ChargeLog.battery_id, func.max(ChargeLog.charged_date))
.group_by(ChargeLog.battery_id).all()
}
active = [b for b in batteries if b.status in ("available", "installed")] active = [b for b in batteries if b.status in ("available", "installed")]
needs_attention = { needs_attention = {
"low_capacity": [ "low_capacity": [
@@ -138,14 +126,57 @@ def create_app(config_object="config"):
if b.battery_percentage is not None and b.battery_percentage < 20 if b.battery_percentage is not None and b.battery_percentage < 20
] if ha_client.enabled else [], ] if ha_client.enabled else [],
} }
return render_template("dashboard.html", batteries=batteries, total_batteries = len(batteries)
avail_count = sum(1 for b in batteries if b.status == "available")
installed_count = sum(1 for b in batteries if b.status == "installed")
retired_count = sum(1 for b in batteries if b.status == "retired")
devices = db.query(Device).filter(Device.parent_id == None).all()
total_devices = len(devices)
full_devices = sum(
1 for d in devices
if d.effective_slots() > 0 and d.effective_installed_count() >= d.effective_slots()
)
partial_devices = sum(
1 for d in devices
if 0 < d.effective_installed_count() < d.effective_slots()
)
empty_devices = sum(1 for d in devices if d.effective_installed_count() == 0)
return render_template("home.html",
total_batteries=total_batteries,
avail_count=avail_count,
installed_count=installed_count,
retired_count=retired_count,
total_devices=total_devices,
full_devices=full_devices,
partial_devices=partial_devices,
empty_devices=empty_devices,
total_charges=total_charges,
charges_last_year=charges_last_year,
needs_attention=needs_attention,
ha_enabled=ha_client.enabled,
today=today)
@app.route("/battery/")
def battery_list():
batteries = db.query(Battery).order_by(Battery.label).all()
storage_locations = [
r[0] for r in db.query(Battery.storage_location)
.filter(Battery.storage_location.isnot(None))
.distinct().order_by(Battery.storage_location).all()
]
devices = db.query(Device).order_by(Device.name).all()
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots and not d.has_children()]
today = date.today()
last_charged_map = {
r[0]: r[1]
for r in db.query(ChargeLog.battery_id, func.max(ChargeLog.charged_date))
.group_by(ChargeLog.battery_id).all()
}
return render_template("battery_list.html", batteries=batteries,
storage_locations=storage_locations, devices=devices, storage_locations=storage_locations, devices=devices,
devices_with_slots=devices_with_slots, devices_with_slots=devices_with_slots,
ha_enabled=ha_client.enabled, ha_enabled=ha_client.enabled,
total_charges=total_charges,
charges_last_year=charges_last_year,
last_charged_map=last_charged_map, last_charged_map=last_charged_map,
needs_attention=needs_attention,
today=today) today=today)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -201,7 +232,7 @@ def create_app(config_object="config"):
purchase_date=purchase_date, storage_location=storage_location)) purchase_date=purchase_date, storage_location=storage_location))
db.commit() db.commit()
flash(f"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success") flash(f"Added {count} {brand} batter{'y' if count == 1 else 'ies'}.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()] brands = [r[0] for r in db.query(Battery.brand).distinct().order_by(Battery.brand).all()]
storage_locations = [ storage_locations = [
@@ -454,7 +485,7 @@ def create_app(config_object="config"):
battery.device_id = device.id battery.device_id = device.id
db.commit() db.commit()
flash(f"{battery.label} assigned to {device.name}.", "success") flash(f"{battery.label} assigned to {device.name}.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
return render_template("assign.html", battery=battery, devices=devices_with_slots) return render_template("assign.html", battery=battery, devices=devices_with_slots)
@@ -471,7 +502,7 @@ def create_app(config_object="config"):
battery.device_id = None battery.device_id = None
db.commit() db.commit()
flash(f"{battery.label} unassigned and marked available.", "success") flash(f"{battery.label} unassigned and marked available.", "success")
return redirect(_safe_next(url_for("dashboard"))) return redirect(_safe_next(url_for("battery_list")))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — retire # Battery — retire
@@ -489,7 +520,7 @@ def create_app(config_object="config"):
battery.device_id = None battery.device_id = None
db.commit() db.commit()
flash(f"{battery.label} has been retired.", "success") flash(f"{battery.label} has been retired.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — unretire # Battery — unretire
@@ -522,7 +553,7 @@ def create_app(config_object="config"):
db.delete(battery) db.delete(battery)
db.commit() db.commit()
flash(f"Battery {label} permanently deleted.", "success") flash(f"Battery {label} permanently deleted.", "success")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
return render_template("battery_delete.html", battery=battery) return render_template("battery_delete.html", battery=battery)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -534,7 +565,7 @@ def create_app(config_object="config"):
ids = request.form.getlist("battery_ids", type=int) ids = request.form.getlist("battery_ids", type=int)
if not ids: if not ids:
flash("No batteries selected.", "error") flash("No batteries selected.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
batteries = db.query(Battery).filter(Battery.id.in_(ids)).all() batteries = db.query(Battery).filter(Battery.id.in_(ids)).all()
action = request.form.get("action") action = request.form.get("action")
@@ -563,7 +594,7 @@ def create_app(config_object="config"):
new_brand = request.form.get("new_brand", "").strip() new_brand = request.form.get("new_brand", "").strip()
if not new_brand: if not new_brand:
flash("Brand name is required.", "error") flash("Brand name is required.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
for b in batteries: for b in batteries:
b.brand = new_brand b.brand = new_brand
db.commit() db.commit()
@@ -572,15 +603,15 @@ def create_app(config_object="config"):
device_id = request.form.get("device_id", type=int) device_id = request.form.get("device_id", type=int)
if not device_id: if not device_id:
flash("Please select a device.", "error") flash("Please select a device.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
device = db.get(Device, device_id) device = db.get(Device, device_id)
if device is None: if device is None:
flash("Device not found.", "error") flash("Device not found.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
if device.has_children(): if device.has_children():
flash(f"{device.name} has sub-components; install batteries into those instead.", "error") flash(f"{device.name} has sub-components; install batteries into those instead.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
already_here = [b for b in batteries if b.device_id == device.id] already_here = [b for b in batteries if b.device_id == device.id]
retired_sel = [b for b in batteries if b.is_retired()] retired_sel = [b for b in batteries if b.is_retired()]
@@ -589,7 +620,7 @@ def create_app(config_object="config"):
if not to_process: if not to_process:
flash("No eligible batteries to install.", "error") flash("No eligible batteries to install.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
free_slots = device.battery_slots - device.installed_count() free_slots = device.battery_slots - device.installed_count()
if len(to_process) > free_slots: if len(to_process) > free_slots:
@@ -598,7 +629,7 @@ def create_app(config_object="config"):
f"but {len(to_process)} need installing.", f"but {len(to_process)} need installing.",
"error", "error",
) )
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
existing_brands = device.installed_brands() existing_brands = device.installed_brands()
new_brands = set(b.brand for b in to_process) new_brands = set(b.brand for b in to_process)
@@ -627,10 +658,10 @@ def create_app(config_object="config"):
allowed = {"brand", "storage_location"} allowed = {"brand", "storage_location"}
if field_name not in allowed: if field_name not in allowed:
flash("Invalid field.", "error") flash("Invalid field.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
if field_name == "brand" and not field_value: if field_name == "brand" and not field_value:
flash("Brand name is required.", "error") flash("Brand name is required.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
for b in batteries: for b in batteries:
setattr(b, field_name, field_value) setattr(b, field_name, field_value)
db.commit() db.commit()
@@ -640,7 +671,7 @@ def create_app(config_object="config"):
date_val = _parse_date(request.form.get("charged_date", "").strip()) date_val = _parse_date(request.form.get("charged_date", "").strip())
if not date_val: if not date_val:
flash("A valid date (YYYY-MM-DD) is required.", "error") flash("A valid date (YYYY-MM-DD) is required.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
increment = 1 if request.form.get("increment_cycles") else 0 increment = 1 if request.form.get("increment_cycles") else 0
for b in batteries: for b in batteries:
_record_charge(db, b, date_val, increment, notes=None) _record_charge(db, b, date_val, increment, notes=None)
@@ -654,7 +685,7 @@ def create_app(config_object="config"):
else: else:
flash("Unknown action.", "error") flash("Unknown action.", "error")
return redirect(url_for("dashboard")) return redirect(url_for("battery_list"))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — list # Devices — list
+21
View File
@@ -61,6 +61,27 @@ function metaSelectChanged(sel, inputId) {
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal(); if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
}); });
// Apply URL params as initial filter state on battery_list and device_list
document.addEventListener('DOMContentLoaded', function() {
var params = new URLSearchParams(window.location.search);
var statusParam = params.get('status');
if (statusParam !== null) {
var statusSel = document.getElementById('filter-status');
if (statusSel && typeof applyFilters === 'function') {
statusSel.value = statusParam;
applyFilters();
}
}
var fillParam = params.get('fill');
if (fillParam !== null) {
var fillSel = document.getElementById('filter-fill');
if (fillSel && typeof applyDeviceFilters === 'function') {
fillSel.value = fillParam;
applyDeviceFilters();
}
}
});
// Global handler: forms with data-confirm attribute // Global handler: forms with data-confirm attribute
document.addEventListener('submit', function(e) { document.addEventListener('submit', function(e) {
var form = e.target; var form = e.target;
+2 -1
View File
@@ -2,9 +2,10 @@
"name": "Battery Tracker", "name": "Battery Tracker",
"short_name": "Batteries", "short_name": "Batteries",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#2563eb", "theme_color": "#1e40af",
"icons": [ "icons": [
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" } { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
+54
View File
@@ -144,6 +144,11 @@
.badge-retired { background: var(--badge-retired-bg); color: var(--badge-retired-text); } .badge-retired { background: var(--badge-retired-bg); color: var(--badge-retired-text); }
.badge-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); } .badge-warning { background: var(--badge-warning-bg); color: var(--badge-warning-text); }
/* Clickable stat cards on home page */
.stat-link { display: block; flex: 1; min-width: 120px; text-decoration: none; }
.stat-link:hover { text-decoration: none; }
.stat-link:hover .card { box-shadow: 0 2px 8px rgba(0,0,0,.18); }
/* Buttons */ /* Buttons */
.btn { display: inline-block; padding: 0.4rem 0.9rem; border-radius: 4px; border: none; cursor: pointer; font-size: 0.875rem; font-family: inherit; text-decoration: none; } .btn { display: inline-block; padding: 0.4rem 0.9rem; border-radius: 4px; border: none; cursor: pointer; font-size: 0.875rem; font-family: inherit; text-decoration: none; }
.btn:hover { text-decoration: none; } .btn:hover { text-decoration: none; }
@@ -300,4 +305,53 @@
.form-actions { flex-direction: column; align-items: stretch; } .form-actions { flex-direction: column; align-items: stretch; }
.form-actions .btn, .form-actions .btn,
.form-actions button { width: 100%; text-align: center; } .form-actions button { width: 100%; text-align: center; }
/* ── Mobile: html bg matches nav so status bar area has no color gap ── */
html { background: #1e40af; }
/* ── Mobile: title-bar nav fills behind status bar, links hidden ── */
nav:not(.bottom-nav) {
padding-top: calc(0.4rem + env(safe-area-inset-top));
padding-bottom: 0.4rem;
justify-content: center;
}
nav:not(.bottom-nav) a:not(.brand) { display: none; }
nav:not(.bottom-nav) .brand { margin-right: 0; font-size: 0.95rem; }
body { padding-bottom: 4.5rem; }
.bottom-nav {
position: fixed;
bottom: 0; left: 0; right: 0;
display: flex;
background: #1e40af;
border-top: 1px solid #1d4ed8;
z-index: 100;
padding-bottom: env(safe-area-inset-bottom);
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.5rem 0.25rem 0.4rem;
color: #bfdbfe;
text-decoration: none;
font-size: 0.65rem;
gap: 0.2rem;
-webkit-tap-highlight-color: transparent;
}
.bottom-nav-item.active { color: #fff; }
.bottom-nav-item svg { display: block; }
}
/* ── Desktop: hide bottom nav ── */
@media (min-width: 641px) {
.bottom-nav { display: none; }
}
/* ── Dark mode: bottom nav + html background ── */
@media (prefers-color-scheme: dark) and (max-width: 640px) {
html { background: #0c1a3b; }
.bottom-nav { background: #0c1a3b; border-top-color: #1e3a8a; }
} }
+27 -18
View File
@@ -5,9 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Battery Tracker{% endblock %}</title> <title>{% block title %}Battery Tracker{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json"> <link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#2563eb"> <meta name="theme-color" content="#1e40af" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0c1a3b" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Batteries"> <meta name="apple-mobile-web-app-title" content="Batteries">
<link rel="apple-touch-icon" href="/static/icon-192.png"> <link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"> <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
@@ -16,10 +17,9 @@
</head> </head>
<body> <body>
<nav> <nav>
<a class="brand" href="{{ url_for('dashboard') }}">Battery Tracker</a> <a class="brand" href="{{ url_for('home') }}">Battery Tracker</a>
<a href="{{ url_for('battery_list') }}">Batteries</a>
<a href="{{ url_for('device_list') }}">Devices</a> <a href="{{ url_for('device_list') }}">Devices</a>
<a href="{{ url_for('battery_add') }}">+ Battery</a>
<a href="{{ url_for('device_add') }}">+ Device</a>
</nav> </nav>
<div class="container"> <div class="container">
@@ -32,19 +32,6 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<footer style="text-align:center;padding:1.25rem 1rem 1.5rem;margin-top:1rem;
border-top:1px solid var(--border);font-size:0.8rem;">
<a href="{{ url_for('export_page') }}"
style="color:var(--text-muted);text-decoration:none;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">Export data</a>
<span style="color:var(--text-muted);margin:0 0.5rem;">·</span>
<a href="{{ url_for('import_page') }}"
style="color:var(--text-muted);text-decoration:none;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'">Import data</a>
</footer>
<div id="confirm-modal" role="dialog" aria-modal="true"> <div id="confirm-modal" role="dialog" aria-modal="true">
<div id="confirm-modal-box"> <div id="confirm-modal-box">
<p id="confirm-modal-msg"></p> <p id="confirm-modal-msg"></p>
@@ -56,5 +43,27 @@
</div> </div>
<script src="{{ url_for('static', filename='app.js') }}" defer></script> <script src="{{ url_for('static', filename='app.js') }}" defer></script>
<nav class="bottom-nav" aria-label="Main navigation">
{% set ep = request.endpoint or '' %}
<a href="{{ url_for('home') }}" class="bottom-nav-item{% if ep == 'home' %} active{% endif %}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<span>Home</span>
</a>
<a href="{{ url_for('battery_list') }}" class="bottom-nav-item{% if ep == 'battery_list' %} active{% endif %}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="7" width="16" height="10" rx="2"/><path d="M22 11v2"/>
</svg>
<span>Batteries</span>
</a>
<a href="{{ url_for('device_list') }}" class="bottom-nav-item{% if ep.startswith('device') and ep != 'device_add' %} active{% endif %}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
<span>Devices</span>
</a>
</nav>
</body> </body>
</html> </html>
+1 -1
View File
@@ -97,7 +97,7 @@
<div class="form-actions"> <div class="form-actions">
<button class="btn btn-primary" type="submit">Add Batteries</button> <button class="btn btn-primary" type="submit">Add Batteries</button>
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Cancel</a> <a class="btn btn-secondary" href="{{ url_for('battery_list') }}">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
+1 -1
View File
@@ -362,7 +362,7 @@
</div> </div>
</div> </div>
<a class="text-muted" href="{{ url_for('dashboard') }}">&larr; Back to Dashboard</a> <a class="text-muted" href="{{ url_for('battery_list') }}">&larr; Back to Batteries</a>
<script> <script>
(function() { (function() {
+561
View File
@@ -0,0 +1,561 @@
{% extends "base.html" %}
{% block title %}Batteries — Battery Tracker{% endblock %}
{% block content %}
<h1>Batteries</h1>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<a class="btn btn-primary btn-sm" href="{{ url_for('battery_add') }}">+ Add Battery</a>
<div style="position:relative;">
<button type="button" id="col-picker-btn" class="btn btn-sm btn-secondary">Columns ▾</button>
<div id="col-picker-panel" style="display:none;position:absolute;right:0;top:calc(100% + 4px);background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:0.75rem 1rem;z-index:100;box-shadow:0 4px 8px rgba(0,0,0,0.1);min-width:180px;">
<div style="font-weight:600;font-size:0.8rem;color:#64748b;margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em;">Show columns</div>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="last-charged" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Last Charged</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="health" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Health %</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="chemistry" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Chemistry</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="capacity" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Capacity</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="storage" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Storage Location</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="purchase" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Purchase Date</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="cycles" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Charge Cycles</label>
{% if ha_enabled %}<label style="display:block;cursor:pointer;font-size:0.875rem;"><input type="checkbox" data-col="ha-pct" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Battery %</label>{% endif %}
</div>
</div>
</div>
<div id="filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;">
<select id="filter-status" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="active" selected>Active (non-retired)</option>
<option value="">All Statuses</option>
<option value="available">Available</option>
<option value="installed">Installed</option>
<option value="retired">Retired</option>
</select>
<select id="filter-brand" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">All Brands</option>
{% for b in batteries|map(attribute='brand')|unique|sort %}
<option value="{{ b }}">{{ b }}</option>
{% endfor %}
</select>
<select id="filter-size" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">All Sizes</option>
{% for s in batteries|map(attribute='size')|select|unique|sort %}
<option value="{{ s }}">{{ s }}</option>
{% endfor %}
</select>
<select id="filter-storage" onchange="applyFilters()" style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">All Locations</option>
{% for loc in storage_locations %}
<option value="{{ loc }}">{{ loc }}</option>
{% endfor %}
</select>
<input type="text" id="filter-text" oninput="applyFilters()" placeholder="Search…"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;">
<input type="number" id="filter-range-from" oninput="applyFilters()" placeholder="# from"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:70px;">
<input type="number" id="filter-range-to" oninput="applyFilters()" placeholder="# to"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:70px;">
<button type="button" id="select-all-btn" onclick="mobileSelectAll()" class="btn btn-sm btn-secondary" style="display:none;">Select all</button>
<button type="button" onclick="resetFilters()" class="btn btn-sm btn-secondary" id="filter-reset" style="display:none;">✕ Reset</button>
<span id="filter-count" style="font-size:0.8rem;color:#64748b;"></span>
</div>
<form method="post" action="{{ url_for('battery_bulk_action') }}" id="bulk-form">
<div id="bulk-toolbar" style="display:none;margin-bottom:0.75rem;padding:0.6rem 0.75rem;background:#f1f5f9;border-radius:6px;align-items:center;gap:0.5rem;flex-wrap:wrap;position:sticky;top:0;z-index:90;box-shadow:0 2px 6px rgba(0,0,0,.08);">
<span id="selected-count" style="font-size:0.85rem;color:#64748b;margin-right:0.25rem;"></span>
<button class="btn btn-sm btn-warning" name="action" value="unassign" type="submit">Unassign</button>
<button class="btn btn-sm btn-secondary" name="action" value="retire" type="submit">Retire</button>
<button class="btn btn-sm btn-danger" name="action" value="delete" type="button"
onclick="bulkActionConfirm(this, 'Permanently delete selected batteries?', 'Delete', 'btn-danger')">Delete</button>
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
<input type="hidden" name="field_name" id="bulk-field-name" value="storage_location">
<select id="bulk-field-select" onchange="updateBulkField(this)"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="storage_location">Storage Location</option>
<option value="brand">Brand</option>
</select>
<!-- Storage Location value -->
<span id="bulk-val-storage_location" style="display:flex;gap:0.25rem;align-items:center;">
<select id="bulk-storage-select" onchange="bulkStorageChanged(this)"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">— select —</option>
{% for loc in storage_locations|default([]) %}
<option value="{{ loc }}">{{ loc }}</option>
{% endfor %}
<option value="__new__"> New location…</option>
</select>
<input type="text" id="bulk-storage-text"
style="display:none;padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:140px;"
placeholder="Type location">
<input type="hidden" name="field_value" id="bulk-field-value-storage" value="">
</span>
<!-- Brand value -->
<span id="bulk-val-brand" style="display:none;">
<input type="text" id="bulk-brand-text" oninput="document.getElementById('bulk-field-value-brand').value=this.value"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;width:160px;"
placeholder="New brand name">
<input type="hidden" name="field_value" id="bulk-field-value-brand" value="">
</span>
<button class="btn btn-sm btn-primary" name="action" value="set_field" type="submit">Apply</button>
</span>
<span style="display:flex;gap:0.35rem;align-items:center;">
<select id="bulk-device-select" name="device_id"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="">— select device —</option>
{% for d in devices_with_slots %}
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %}
</select>
<button class="btn btn-sm btn-primary" name="action" value="install_device" type="button"
onclick="confirmInstallDevice(this)">Install in device</button>
</span>
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
<input type="date" name="charged_date" id="bulk-charged-date" value="{{ today.isoformat() }}"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<label style="font-size:0.85rem;display:flex;align-items:center;gap:0.25rem;cursor:pointer;">
<input type="checkbox" name="increment_cycles" id="bulk-increment-cycles" value="1" checked>
+cycle
</label>
<button class="btn btn-sm btn-primary" name="action" value="log_charged" type="submit"
onclick="return validateBulkCharge()">Log Charged</button>
</span>
</div>
<div class="table-wrap">
<table class="responsive-table">
<thead>
<tr>
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
<th data-sortable="label">Label</th>
<th data-sortable="brand">Brand</th>
<th>Size</th>
<th class="col-last-charged" style="display:none;" data-sortable="last-charged">Last Charged</th>
<th class="col-health" style="display:none;" data-sortable="health">Health</th>
<th class="col-chemistry" style="display:none;">Chemistry</th>
<th class="col-capacity" style="display:none;">Capacity</th>
<th class="col-storage" style="display:none;">Storage</th>
<th class="col-purchase" style="display:none;">Purchase Date</th>
<th class="col-cycles" style="display:none;" data-sortable="cycles">Cycles</th>
{% if ha_enabled %}<th class="col-ha-pct" style="display:none;" data-sortable="ha-pct">Bat %</th>{% endif %}
<th data-sortable="status">Status</th>
<th>Assigned To</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for b in batteries %}
<tr data-brand="{{ b.brand }}" data-size="{{ b.size or '' }}" data-status="{{ b.status }}" data-storage="{{ b.storage_location or '' }}" data-label="{{ b.label }}">
<td data-label=""><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
<td data-label="Label" data-sort-col="label" data-sort="{{ b.label }}"><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
<td data-label="Brand" data-sort-col="brand" data-sort="{{ b.brand }}">{{ b.brand }}</td>
<td data-label="Size">{{ b.size or '—' }}</td>
<td data-label="Last Charged" class="col-last-charged" style="display:none;"
data-sort-col="last-charged" data-sort="{{ last_charged_map.get(b.id, '') }}"
data-charged="{{ last_charged_map.get(b.id, '') }}"></td>
<td data-label="Health" class="col-health" style="display:none;"
data-sort-col="health" data-sort="{% if b.tested_capacity_mah and b.capacity_mah %}{{ (b.tested_capacity_mah / b.capacity_mah * 100)|int }}{% endif %}">
{% if b.tested_capacity_mah and b.capacity_mah %}
{% set hp = (b.tested_capacity_mah / b.capacity_mah * 100)|int %}
<span class="badge {% if hp >= 80 %}badge-available{% elif hp >= 60 %}badge-warning{% else %}badge-retired{% endif %}">{{ hp }}%</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td data-label="Chemistry" class="col-chemistry" style="display:none;">{{ b.chemistry or '—' }}</td>
<td data-label="Capacity" class="col-capacity" style="display:none;">
{% if b.capacity_mah %}
{% if b.tested_capacity_mah %}{{ b.tested_capacity_mah }}/{{ b.capacity_mah }} mAh
{% else %}{{ b.capacity_mah }} mAh{% endif %}
{% else %}—{% endif %}
</td>
<td data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
<td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
<td data-label="Cycles" class="col-cycles" style="display:none;"
data-sort-col="cycles" data-sort="{{ b.charge_cycles or '' }}">{{ b.charge_cycles or '—' }}</td>
{% if ha_enabled %}
<td data-label="Bat %" class="col-ha-pct" style="display:none;"
data-sort-col="ha-pct" data-sort="{{ b.battery_percentage if b.battery_percentage is not none else '' }}">
{% if b.battery_percentage is not none %}
{% if b.battery_percentage < 20 and b.status != 'retired' %}
<span class="badge badge-warning" title="Low — consider replacing">⚠ {{ b.battery_percentage }}%</span>
{% else %}{{ b.battery_percentage }}%{% endif %}
{% else %}—{% endif %}
</td>
{% endif %}
<td data-label="Status" data-sort-col="status" data-sort="{{ b.status }}">
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
</td>
<td data-label="Assigned To">
{% if b.device %}
{% if b.device.parent %}
<a href="{{ url_for('device_detail', device_id=b.device.parent.id) }}">{{ b.device.parent.name }}</a>
<span class="text-muted"> / </span>
{% endif %}
<a href="{{ url_for('device_detail', device_id=b.device.id) }}">{{ b.device.name }}</a>
{% if b.device.has_mixed_brands() %}
<span class="badge badge-warning" title="Mixed brands in this device">⚠ mixed</span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td data-label="Actions" style="white-space:nowrap;">
<a class="btn btn-sm btn-secondary" href="{{ url_for('battery_detail', battery_id=b.id) }}">View</a>
{% if b.is_available() %}
<select id="qas-{{ b.id }}"
style="padding:0.2rem 0.3rem;font-size:0.8rem;border:1px solid #cbd5e1;border-radius:4px;max-width:110px;vertical-align:middle;">
<option value="">— assign —</option>
{% for d in devices_with_slots %}
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %}
</select>
<button type="button" class="btn btn-sm btn-primary"
onclick="quickAssign('{{ url_for('battery_assign', battery_id=b.id) }}', {{ b.id }})"></button>
{% endif %}
{% if b.is_installed() %}
<button class="btn btn-sm btn-warning" type="submit"
formaction="{{ url_for('battery_unassign', battery_id=b.id) }}">Unassign</button>
{% endif %}
{% if not b.is_retired() %}
<button class="btn btn-sm btn-secondary" type="submit"
formaction="{{ url_for('battery_retire', battery_id=b.id) }}">Retire</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="12" class="text-muted" style="text-align:center;padding:1rem;">No batteries found. <a href="{{ url_for('battery_add') }}">Add some.</a></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="pagination-bar" style="display:flex;align-items:center;gap:0.75rem;margin-top:0.75rem;flex-wrap:wrap;">
<button id="prev-page" class="btn btn-sm btn-secondary" type="button"
onclick="currentPage--; applyPagination();" disabled>&#8592; Prev</button>
<span id="page-info" style="color:var(--text-muted);font-size:0.9rem;"></span>
<button id="next-page" class="btn btn-sm btn-secondary" type="button"
onclick="currentPage++; applyPagination();" disabled>Next &#8594;</button>
<select id="page-size-select" onchange="PAGE_SIZE=+this.value||Infinity;currentPage=1;applyPagination();"
style="padding:0.2rem 0.4rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
<option value="10">10 / page</option>
<option value="25" selected>25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
<option value="">All</option>
</select>
</div>
</form>
</div>
<script>
var CSRF_TOKEN = '{{ csrf_token() }}';
var selectAll = document.getElementById('select-all');
var toolbar = document.getElementById('bulk-toolbar');
var countEl = document.getElementById('selected-count');
var selectAllBtn = document.getElementById('select-all-btn');
var PAGE_SIZE = 25;
var currentPage = 1;
function visibleCbs() {
return Array.prototype.filter.call(
document.querySelectorAll('.row-cb'),
function(cb) { return cb.closest('tr').style.display !== 'none'; }
);
}
function updateToolbar() {
var checked = document.querySelectorAll('.row-cb:checked');
var vis = visibleCbs();
var n = checked.length;
toolbar.style.display = n > 0 ? 'flex' : 'none';
countEl.textContent = n + ' selected';
var visChecked = vis.filter(function(cb) { return cb.checked; });
selectAll.indeterminate = visChecked.length > 0 && visChecked.length < vis.length;
selectAll.checked = vis.length > 0 && visChecked.length === vis.length;
if (selectAllBtn) {
selectAllBtn.style.display = vis.length > 0 ? '' : 'none';
selectAllBtn.textContent = (vis.length > 0 && visChecked.length === vis.length)
? 'Deselect all' : 'Select all';
}
}
function mobileSelectAll() {
var vis = visibleCbs();
var allChecked = vis.length > 0 && vis.every(function(cb) { return cb.checked; });
vis.forEach(function(cb) { cb.checked = !allChecked; });
updateToolbar();
}
document.querySelectorAll('.row-cb').forEach(function(cb) {
cb.addEventListener('change', updateToolbar);
});
selectAll.addEventListener('change', function() {
visibleCbs().forEach(function(cb) { cb.checked = selectAll.checked; });
updateToolbar();
});
function applyFilters() {
var status = document.getElementById('filter-status').value;
var brand = document.getElementById('filter-brand').value;
var size = document.getElementById('filter-size').value;
var storage = document.getElementById('filter-storage').value;
var text = document.getElementById('filter-text').value.trim().toLowerCase();
var rangeFromVal = document.getElementById('filter-range-from').value.trim();
var rangeToVal = document.getElementById('filter-range-to').value.trim();
var rangeMin = rangeFromVal !== '' ? parseInt(rangeFromVal, 10) : null;
var rangeMax = rangeToVal !== '' ? parseInt(rangeToVal, 10) : null;
var anyActive = (status && status !== 'active') || brand || size || storage || text || rangeFromVal || rangeToVal;
document.getElementById('filter-reset').style.display = anyActive ? '' : 'none';
var rows = document.querySelectorAll('tbody tr[data-brand]');
var filtered = 0;
rows.forEach(function(row) {
var show = true;
if (status === 'active') {
if (row.dataset.status === 'retired') show = false;
} else if (status) {
if (row.dataset.status !== status) show = false;
}
if (brand && row.dataset.brand !== brand) show = false;
if (size && row.dataset.size !== size) show = false;
if (storage && row.dataset.storage !== storage) show = false;
if (text && row.textContent.toLowerCase().indexOf(text) === -1) show = false;
if (rangeMin !== null || rangeMax !== null) {
var lbl = row.dataset.label || '';
var m = lbl.match(/(\d+)\s*$/);
var n = m ? parseInt(m[1], 10) : null;
if (n === null) show = false;
else if (rangeMin !== null && n < rangeMin) show = false;
else if (rangeMax !== null && n > rangeMax) show = false;
}
row.dataset.filteredOut = show ? '' : '1';
if (show) filtered++;
});
var fc = document.getElementById('filter-count');
fc.textContent = anyActive ? (filtered + ' of ' + rows.length + ' shown') : '';
currentPage = 1;
applyPagination();
}
function applyPagination() {
var allRows = Array.from(document.querySelectorAll('tbody tr[data-brand]'));
var visible = allRows.filter(function(r) { return !r.dataset.filteredOut; });
var totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE));
if (currentPage > totalPages) currentPage = totalPages;
var start = (currentPage - 1) * PAGE_SIZE;
allRows.forEach(function(r) { r.style.display = 'none'; });
visible.slice(start, start + PAGE_SIZE).forEach(function(r) { r.style.display = ''; });
var info = document.getElementById('page-info');
var prev = document.getElementById('prev-page');
var next = document.getElementById('next-page');
if (info) info.textContent = visible.length > PAGE_SIZE
? 'Page ' + currentPage + ' of ' + totalPages : '';
if (prev) prev.disabled = currentPage <= 1;
if (next) next.disabled = currentPage >= totalPages;
updateToolbar();
}
function confirmInstallDevice(btn) {
var deviceSel = document.getElementById('bulk-device-select');
if (!deviceSel.value) { deviceSel.focus(); return; }
var movers = Array.prototype.filter.call(
document.querySelectorAll('.row-cb:checked'),
function(cb) { return cb.closest('tr').dataset.status === 'installed'; }
);
if (movers.length > 0) {
var n = movers.length;
showConfirm(
n + ' selected batter' + (n === 1 ? 'y is' : 'ies are') +
' already installed elsewhere. Unassign and move to the selected device?',
function() { submitWithAction(btn); },
'Move', 'btn-warning'
);
} else {
submitWithAction(btn);
}
}
function bulkActionConfirm(btn, msg, okLabel, okClass) {
showConfirm(msg, function() { submitWithAction(btn); }, okLabel, okClass);
}
function submitWithAction(btn) {
var form = btn.form || document.getElementById('bulk-form');
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = btn.name; inp.value = btn.value;
form.appendChild(inp);
form.submit();
}
function quickAssign(action, batteryId) {
var sel = document.getElementById('qas-' + batteryId);
if (!sel.value) { sel.focus(); return; }
var f = document.createElement('form');
f.method = 'post'; f.action = action;
var deviceInp = document.createElement('input');
deviceInp.type = 'hidden'; deviceInp.name = 'device_id'; deviceInp.value = sel.value;
f.appendChild(deviceInp);
var csrfInp = document.createElement('input');
csrfInp.type = 'hidden'; csrfInp.name = 'csrf_token'; csrfInp.value = CSRF_TOKEN;
f.appendChild(csrfInp);
document.body.appendChild(f);
f.submit();
}
function resetFilters() {
document.getElementById('filter-status').value = 'active';
['filter-brand','filter-size','filter-storage'].forEach(function(id) {
document.getElementById(id).value = '';
});
document.getElementById('filter-text').value = '';
document.getElementById('filter-range-from').value = '';
document.getElementById('filter-range-to').value = '';
applyFilters();
}
function updateBulkField(sel) {
var field = sel.value;
document.getElementById('bulk-field-name').value = field;
document.getElementById('bulk-val-storage_location').style.display = field === 'storage_location' ? 'flex' : 'none';
document.getElementById('bulk-val-brand').style.display = field === 'brand' ? 'flex' : 'none';
document.getElementById('bulk-field-value-storage').disabled = (field !== 'storage_location');
document.getElementById('bulk-field-value-brand').disabled = (field !== 'brand');
}
// initialise disabled state on page load
document.getElementById('bulk-field-value-brand').disabled = true;
updateToolbar();
// Column picker
var COL_KEY = 'battery_cols';
var ALL_COLS = ['last-charged','health','chemistry','capacity','storage','purchase','cycles'{% if ha_enabled %},'ha-pct'{% endif %}];
function toggleCol(cb) {
var col = cb.dataset.col;
document.querySelectorAll('.col-' + col).forEach(function(el) {
el.style.display = cb.checked ? '' : 'none';
});
var prefs = JSON.parse(localStorage.getItem(COL_KEY) || '{}');
prefs[col] = cb.checked;
localStorage.setItem(COL_KEY, JSON.stringify(prefs));
}
(function loadColPrefs() {
var prefs = JSON.parse(localStorage.getItem(COL_KEY) || '{}');
ALL_COLS.forEach(function(col) {
if (prefs[col]) {
var cb = document.querySelector('[data-col="' + col + '"]');
if (cb) { cb.checked = true; toggleCol(cb); }
}
});
}());
// Relative age for "Last Charged" column
function relAge(dateStr) {
if (!dateStr) return {text: 'Never', cls: 'text-danger'};
var days = Math.floor((Date.now() - new Date(dateStr + 'T00:00:00')) / 86400000);
if (days <= 0) return {text: 'Today', cls: ''};
if (days === 1) return {text: 'Yesterday', cls: ''};
if (days < 14) return {text: days + ' days ago', cls: ''};
if (days < 60) return {text: Math.floor(days / 7) + ' wks ago', cls: days > 30 ? 'text-warning' : ''};
if (days < 365) return {text: Math.floor(days / 30) + ' mo ago', cls: days > 180 ? 'text-danger' : 'text-warning'};
return {text: Math.floor(days / 365) + ' yr ago', cls: 'text-danger'};
}
document.querySelectorAll('td[data-charged]').forEach(function(td) {
var r = relAge(td.dataset.charged);
td.innerHTML = '<span class="' + r.cls + '">' + r.text + '</span>';
});
// Sortable columns
var _sortCol = null, _sortDir = 1;
var _origOrder = null;
function _captureOrder() {
if (!_origOrder) {
_origOrder = Array.from(document.querySelectorAll('tbody tr[data-brand]'));
}
}
document.querySelectorAll('th[data-sortable]').forEach(function(th) {
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
var ind = document.createElement('span');
ind.className = 'sort-ind';
ind.style.fontSize = '0.7rem';
th.appendChild(ind);
th.addEventListener('click', function() {
var col = th.dataset.sortable;
if (_sortCol === col) {
_sortDir = _sortDir === 1 ? -1 : 0;
} else {
_sortCol = col; _sortDir = 1;
}
document.querySelectorAll('th[data-sortable] .sort-ind').forEach(function(s) { s.textContent = ''; });
if (_sortDir === 0) {
_sortCol = null; _sortDir = 1;
_captureOrder();
var tbody = document.querySelector('tbody');
_origOrder.forEach(function(r) { tbody.appendChild(r); });
applyPagination();
return;
}
ind.textContent = _sortDir === 1 ? ' ▲' : ' ▼';
_captureOrder();
var tbody = document.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr[data-brand]'));
rows.sort(function(a, b) {
var at = a.querySelector('td[data-sort-col="' + col + '"]');
var bt = b.querySelector('td[data-sort-col="' + col + '"]');
var av = at ? (at.dataset.sort || '') : '';
var bv = bt ? (bt.dataset.sort || '') : '';
// empty values always sort last
if (!av && !bv) return 0;
if (!av) return 1;
if (!bv) return -1;
var an = parseFloat(av), bn = parseFloat(bv);
if (!isNaN(an) && !isNaN(bn)) return (an - bn) * _sortDir;
return av.localeCompare(bv) * _sortDir;
});
rows.forEach(function(r) { tbody.appendChild(r); });
applyPagination();
});
});
document.getElementById('col-picker-btn').addEventListener('click', function(e) {
var panel = document.getElementById('col-picker-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
e.stopPropagation();
});
document.addEventListener('click', function() {
document.getElementById('col-picker-panel').style.display = 'none';
});
function validateBulkCharge() {
var d = document.getElementById('bulk-charged-date');
if (!d.value) { d.focus(); return false; }
return true;
}
function bulkStorageChanged(sel) {
var text = document.getElementById('bulk-storage-text');
var hidden = document.getElementById('bulk-field-value-storage');
if (sel.value === '__new__') {
text.style.display = '';
text.value = '';
text.oninput = function() { hidden.value = text.value; };
text.focus();
hidden.value = '';
} else {
text.style.display = 'none';
hidden.value = sel.value;
}
}
applyFilters();
</script>
{% endblock %}
+3
View File
@@ -5,6 +5,9 @@
<h1>Devices</h1> <h1>Devices</h1>
<div class="card"> <div class="card">
<div style="display:flex;justify-content:flex-start;margin-bottom:0.5rem;">
<a class="btn btn-primary btn-sm" href="{{ url_for('device_add') }}">+ Add Device</a>
</div>
<div id="device-filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;"> <div id="device-filter-bar" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.75rem;">
<select id="filter-type" onchange="applyDeviceFilters()" <select id="filter-type" onchange="applyDeviceFilters()"
style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;"> style="padding:0.25rem 0.5rem;font-size:0.85rem;border:1px solid #cbd5e1;border-radius:4px;">
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Home — Battery Tracker{% endblock %}
{% block content %}
<h1>Battery Tracker</h1>
<h2 style="font-size:1rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.6rem;">Batteries</h2>
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<a class="stat-link" href="{{ url_for('battery_list') }}?status=">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;">{{ total_batteries }}</div>
<div class="text-muted">Total</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('battery_list') }}?status=available">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-available);">{{ avail_count }}</div>
<div class="text-muted">Available</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('battery_list') }}?status=installed">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-installed);">{{ installed_count }}</div>
<div class="text-muted">Installed</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('battery_list') }}?status=retired">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-retired);">{{ retired_count }}</div>
<div class="text-muted">Retired</div>
</div>
</a>
{% if ha_enabled and needs_attention.low_pct %}
<a href="#needs-attention"
onclick="var d=document.getElementById('needs-attention');d.open=true;"
style="flex:1;min-width:120px;text-decoration:none;">
<div class="card" style="text-align:center;border:2px solid #f59e0b;cursor:pointer;">
<div style="font-size:1.8rem;font-weight:700;color:#f59e0b;">{{ needs_attention.low_pct|length }}</div>
<div class="text-muted">Low Battery</div>
</div>
</a>
{% endif %}
</div>
<h2 style="font-size:1rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.6rem;">Devices</h2>
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<a class="stat-link" href="{{ url_for('device_list') }}">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;">{{ total_devices }}</div>
<div class="text-muted">Total</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('device_list') }}?fill=full">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-installed);">{{ full_devices }}</div>
<div class="text-muted">Full</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('device_list') }}?fill=partial">
<div class="card" style="text-align:center;">
<div style="font-size:1.8rem;font-weight:700;color:var(--count-available);">{{ partial_devices }}</div>
<div class="text-muted">Partial</div>
</div>
</a>
<a class="stat-link" href="{{ url_for('device_list') }}?fill=empty">
<div class="card" style="text-align:center;{% if empty_devices > 0 %}border:2px solid #f59e0b;{% endif %}">
<div style="font-size:1.8rem;font-weight:700;{% if empty_devices > 0 %}color:#f59e0b;{% else %}color:var(--count-retired);{% endif %}">{{ empty_devices }}</div>
<div class="text-muted">Empty</div>
</div>
</a>
</div>
{% if total_charges %}
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1rem;">
<span class="badge" style="font-size:0.875rem;padding:0.35rem 0.75rem;">Charged <strong>{{ total_charges }}</strong>&times; total</span>
<span class="badge" style="font-size:0.875rem;padding:0.35rem 0.75rem;"><strong>{{ charges_last_year }}</strong>&times; in last year</span>
</div>
{% endif %}
{% set na_low_cap = needs_attention.low_capacity %}
{% set na_low_pct = needs_attention.low_pct %}
{% if na_low_cap or na_low_pct %}
<details id="needs-attention" class="card" style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-weight:600;color:var(--text-warning);list-style:none;display:flex;align-items:center;gap:0.5rem;">
<span>&#9888;</span>
<span>Needs Attention &nbsp;<span class="badge badge-warning">{{ (na_low_cap|length) + (na_low_pct|length) }}</span></span>
</summary>
<div style="margin-top:0.75rem;display:flex;flex-wrap:wrap;gap:1.5rem;">
{% if na_low_cap %}
<div>
<div style="font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.4rem;">Low Capacity (&lt;80%)</div>
{% for b in na_low_cap %}
<div style="font-size:0.875rem;margin-bottom:0.25rem;">
<a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a>
<span class="text-muted">— {{ (b.tested_capacity_mah / b.capacity_mah * 100)|int }}% of rated</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if na_low_pct %}
<div>
<div style="font-size:0.75rem;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.4rem;">Low Battery %</div>
{% for b in na_low_pct %}
<div style="font-size:0.875rem;margin-bottom:0.25rem;">
<a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a>
<span class="text-muted">— {{ b.battery_percentage }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endif %}
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-top:0.5rem;max-width:480px;">
<a class="btn btn-primary" style="text-align:center;" href="{{ url_for('battery_add') }}">+ Add Battery</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('battery_list') }}">View All Batteries</a>
<a class="btn btn-primary" style="text-align:center;" href="{{ url_for('device_add') }}">+ Add Device</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('device_list') }}">View All Devices</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('export_page') }}">Export Data</a>
<a class="btn btn-secondary" style="text-align:center;" href="{{ url_for('import_page') }}">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>
+41 -6
View File
@@ -30,17 +30,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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -197,7 +232,7 @@ def test_unassign_next_honors_local_path(seeded_client):
def test_unassign_next_rejects_external_redirect(seeded_client): def test_unassign_next_rejects_external_redirect(seeded_client):
seeded_client.post("/battery/1/assign", data={"device_id": "1"}) seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"}) resp = seeded_client.post("/battery/1/unassign", data={"next": "//evil.com/phish"})
assert resp.headers["Location"] == "/" assert resp.headers["Location"] == "/battery/"
def test_unassign_all_next_rejects_external_redirect(seeded_client): def test_unassign_all_next_rejects_external_redirect(seeded_client):
@@ -741,7 +776,7 @@ def test_import_creates_devices_and_batteries(client):
resp = _post_import(client, payload) resp = _post_import(client, payload)
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Import Results" in resp.data assert b"Import Results" in resp.data
dash = client.get("/") dash = client.get("/battery/")
assert b"Eneloop 001" in dash.data assert b"Eneloop 001" in dash.data
assert b"RC Car" in dash.data assert b"RC Car" in dash.data
@@ -1054,10 +1089,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
+2 -2
View File
@@ -314,7 +314,7 @@ def test_poll_handles_api_error_gracefully(ha_app, ha_client_f):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_dashboard_shows_ha_column_when_enabled(ha_client_f): def test_dashboard_shows_ha_column_when_enabled(ha_client_f):
resp = ha_client_f.get("/") resp = ha_client_f.get("/battery/")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"ha-pct" in resp.data assert b"ha-pct" in resp.data
@@ -351,7 +351,7 @@ def test_dashboard_no_warning_for_high_percentage(ha_app, ha_client_f):
s.commit() s.commit()
s.close() s.close()
resp = ha_client_f.get("/") resp = ha_client_f.get("/battery/")
assert b"85%" in resp.data assert b"85%" in resp.data
# badge-warning should NOT appear for this battery's percentage # badge-warning should NOT appear for this battery's percentage
# (may still appear in page for other reasons, so check row contains 85% but not warning badge near it) # (may still appear in page for other reasons, so check row contains 85% but not warning badge near it)