Add needs-attention alerts, last charged, health %, quick charge, sortable columns
This commit is contained in:
@@ -76,17 +76,38 @@ def create_app(config_object="config"):
|
||||
]
|
||||
devices = db.query(Device).order_by(Device.name).all()
|
||||
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots]
|
||||
one_year_ago = (date.today() - timedelta(days=365)).isoformat()
|
||||
today = date.today()
|
||||
one_year_ago = (today - timedelta(days=365)).isoformat()
|
||||
total_charges = db.query(func.count(ChargeLog.id)).scalar() or 0
|
||||
charges_last_year = (db.query(func.count(ChargeLog.id))
|
||||
.filter(ChargeLog.charged_date >= one_year_ago)
|
||||
.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")]
|
||||
needs_attention = {
|
||||
"low_capacity": [
|
||||
b for b in active
|
||||
if b.tested_capacity_mah and b.capacity_mah
|
||||
and b.tested_capacity_mah < 0.8 * b.capacity_mah
|
||||
],
|
||||
"low_pct": [
|
||||
b for b in batteries
|
||||
if b.battery_percentage is not None and b.battery_percentage < 20
|
||||
] if ha_client.enabled else [],
|
||||
}
|
||||
return render_template("dashboard.html", batteries=batteries,
|
||||
storage_locations=storage_locations, devices=devices,
|
||||
devices_with_slots=devices_with_slots,
|
||||
ha_enabled=ha_client.enabled,
|
||||
total_charges=total_charges,
|
||||
charges_last_year=charges_last_year)
|
||||
charges_last_year=charges_last_year,
|
||||
last_charged_map=last_charged_map,
|
||||
needs_attention=needs_attention,
|
||||
today=today)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — add
|
||||
|
||||
+139
-11
@@ -44,12 +44,49 @@
|
||||
</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 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>⚠</span>
|
||||
<span>Needs Attention <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 (<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 class="card">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:0.5rem;">
|
||||
<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>
|
||||
@@ -159,16 +196,18 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:1.5rem;"><input type="checkbox" id="select-all" title="Select all"></th>
|
||||
<th>Label</th>
|
||||
<th>Brand</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;">Cycles</th>
|
||||
{% if ha_enabled %}<th class="col-ha-pct" style="display:none;">Bat %</th>{% endif %}
|
||||
<th>Status</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>
|
||||
@@ -177,9 +216,19 @@
|
||||
{% for b in batteries %}
|
||||
<tr data-brand="{{ b.brand }}" data-size="{{ b.size or '' }}" data-status="{{ b.status }}" data-storage="{{ b.storage_location or '' }}">
|
||||
<td data-label=""><input type="checkbox" name="battery_ids" value="{{ b.id }}" class="row-cb"></td>
|
||||
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}"><strong>{{ b.label }}</strong></a></td>
|
||||
<td data-label="Brand">{{ b.brand }}</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 %}
|
||||
@@ -189,9 +238,11 @@
|
||||
</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;">{{ b.charge_cycles 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;">
|
||||
<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 %}
|
||||
<span class="badge badge-warning" title="Low — consider replacing">⚠ {{ b.battery_percentage }}%</span>
|
||||
@@ -199,7 +250,7 @@
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td data-label="Status">
|
||||
<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">
|
||||
@@ -215,6 +266,14 @@
|
||||
<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 not b.is_retired() %}
|
||||
<form class="inline" method="post" action="{{ url_for('battery_charge_log_add', battery_id=b.id) }}"
|
||||
title="Log charge for today">
|
||||
<input type="hidden" name="charged_date" value="{{ today.isoformat() }}">
|
||||
<button class="btn btn-sm btn-secondary" type="submit">✓</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% 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;">
|
||||
@@ -385,7 +444,7 @@ updateToolbar();
|
||||
|
||||
// Column picker
|
||||
var COL_KEY = 'battery_cols';
|
||||
var ALL_COLS = ['chemistry','capacity','storage','purchase','cycles'{% if ha_enabled %},'ha-pct'{% endif %}];
|
||||
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;
|
||||
@@ -407,6 +466,75 @@ function 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); });
|
||||
return;
|
||||
}
|
||||
ind.textContent = _sortDir === 1 ? ' \u25b2' : ' \u25bc';
|
||||
_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); });
|
||||
});
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user