Add needs-attention alerts, last charged, health %, quick charge, sortable columns

This commit is contained in:
2026-04-14 18:49:07 -05:00
parent 8fb03e1fa3
commit c7b7c24a7e
2 changed files with 162 additions and 13 deletions
+139 -11
View File
@@ -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>&#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 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">&#10003;</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';