Collapse history sections to latest+modal; add charts and dashboard stats

This commit is contained in:
2026-04-14 09:06:10 -05:00
parent 080768bf92
commit cd3eb046d7
3 changed files with 367 additions and 90 deletions
+26 -3
View File
@@ -2,7 +2,8 @@ from flask import Flask, render_template, redirect, url_for, request, flash, abo
from sqlalchemy import create_engine, func from sqlalchemy import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker
from datetime import datetime from datetime import datetime, date, timedelta
import json
from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog
@@ -61,10 +62,17 @@ def create_app(config_object="config"):
] ]
devices = db.query(Device).order_by(Device.name).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] devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots]
one_year_ago = (date.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
return render_template("dashboard.html", batteries=batteries, return render_template("dashboard.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)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — add # Battery — add
@@ -152,11 +160,26 @@ def create_app(config_object="config"):
.filter_by(battery_id=battery_id) .filter_by(battery_id=battery_id)
.order_by(BatteryPctLog.recorded_at.desc()) .order_by(BatteryPctLog.recorded_at.desc())
.all()) .all())
charge_logs_json = json.dumps([
{"id": l.id, "date": l.charged_date, "cycles": l.increment_cycles, "notes": l.notes or ""}
for l in charge_logs
])
capacity_tests_json = json.dumps([
{"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)
])
pct_logs_json = json.dumps([
{"recorded_at": str(l.recorded_at), "pct": l.percentage, "source": l.source or ""}
for l 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,
capacity_tests=capacity_tests, capacity_tests=capacity_tests,
charge_logs=charge_logs, charge_logs=charge_logs,
pct_logs=pct_logs) pct_logs=pct_logs,
charge_logs_json=charge_logs_json,
capacity_tests_json=capacity_tests_json,
pct_logs_json=pct_logs_json)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — edit notes # Battery — edit notes
+325 -78
View File
@@ -98,42 +98,24 @@
width="500" height="140"></canvas> width="500" height="140"></canvas>
{% endif %} {% endif %}
{% if capacity_tests %} {% set cap_sorted = capacity_tests|sort(attribute='tested_date', reverse=True) %}
<div class="table-wrap"> {% if cap_sorted %}
<table class="responsive-table"> {% set t = cap_sorted[0] %}
<thead> <div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;">
<tr> <span>
<th>Date</th> <strong>{{ t.tested_date }}</strong> — {{ t.tested_capacity_mah }} mAh
<th>Capacity</th>
{% if battery.capacity_mah %}<th>Health</th>{% endif %}
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in capacity_tests|sort(attribute='tested_date', reverse=True) %}
<tr>
<td data-label="Date">{{ t.tested_date }}</td>
<td data-label="Capacity">{{ t.tested_capacity_mah }} mAh</td>
{% if battery.capacity_mah %} {% if battery.capacity_mah %}
{% set pct = (t.tested_capacity_mah / battery.capacity_mah * 100)|round|int %} {% set pct = (t.tested_capacity_mah / battery.capacity_mah * 100)|round|int %}
{% if pct >= 80 %}{% set hc = "health-good" %} {% if pct >= 80 %}{% set hc = "health-good" %}
{% elif pct >= 60 %}{% set hc = "health-warn" %} {% elif pct >= 60 %}{% set hc = "health-warn" %}
{% else %}{% set hc = "health-bad" %}{% endif %} {% else %}{% set hc = "health-bad" %}{% endif %}
<td data-label="Health"><span class="{{ hc }}">{{ pct }}%</span></td> <span class="{{ hc }}">{{ pct }}%</span>
{% endif %}
{% if t.notes %}<span class="text-muted">— {{ t.notes }}</span>{% endif %}
</span>
{% if cap_sorted|length > 1 %}
<button class="btn btn-sm btn-secondary" onclick="openHistModal('cap-modal')">View all ({{ cap_sorted|length }}) &rarr;</button>
{% endif %} {% endif %}
<td data-label="Notes" class="text-muted">{{ t.notes or '—' }}</td>
<td data-label="">
<form class="inline" method="post"
action="{{ url_for('battery_capacity_test_delete', battery_id=battery.id, test_id=t.id) }}"
data-confirm="Delete this test record?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %} {% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No test records yet.</p> <p class="text-muted" style="margin-bottom:0.75rem;">No test records yet.</p>
@@ -165,33 +147,16 @@
<h2>Charge History</h2> <h2>Charge History</h2>
{% if charge_logs %} {% if charge_logs %}
<div class="table-wrap"> {% set cl = charge_logs[0] %}
<table class="responsive-table"> <div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;">
<thead> <span>
<tr> <strong>{{ cl.charged_date }}</strong>
<th>Date</th> {% if cl.increment_cycles %}<span class="text-muted">+cycle</span>{% endif %}
<th>+Cycle</th> {% if cl.notes %}<span class="text-muted">— {{ cl.notes }}</span>{% endif %}
<th>Notes</th> </span>
<th></th> {% if charge_logs|length > 1 %}
</tr> <button class="btn btn-sm btn-secondary" onclick="openHistModal('chg-modal')">View all ({{ charge_logs|length }}) &rarr;</button>
</thead> {% endif %}
<tbody>
{% for log in charge_logs %}
<tr>
<td data-label="Date">{{ log.charged_date }}</td>
<td data-label="+Cycle">{{ '✓' if log.increment_cycles else '—' }}</td>
<td data-label="Notes" class="text-muted">{{ log.notes or '—' }}</td>
<td data-label="">
<form class="inline" method="post"
action="{{ url_for('battery_charge_log_delete', battery_id=battery.id, log_id=log.id) }}"
data-confirm="Delete this charge log entry?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %} {% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No charge log entries yet.</p> <p class="text-muted" style="margin-bottom:0.75rem;">No charge log entries yet.</p>
@@ -223,28 +188,28 @@
<!-- Percentage History --> <!-- Percentage History -->
<div class="card"> <div class="card">
<h2>Percentage History</h2> <h2>Percentage History</h2>
{% if pct_logs %}
<div class="table-wrap"> {% if pct_logs|length >= 2 %}
<table class="responsive-table"> <canvas id="pct-chart"
<thead> style="width:100%;max-width:500px;height:140px;display:block;margin-bottom:1rem;"
<tr><th>Date / Time</th><th>%</th><th>Source</th></tr> width="500" height="140"></canvas>
</thead> {% endif %}
<tbody>
{% for entry in pct_logs %} {% if pct_logs %}
<tr> {% set pl = pct_logs[0] %}
<td data-label="Date / Time">{{ entry.recorded_at }}</td> <div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:0.5rem;">
<td data-label="%"> <span>
{% if entry.percentage < 20 %} <strong>{{ pl.recorded_at }}</strong>
<span class="badge badge-warning">⚠ {{ entry.percentage }}%</span> {% if pl.percentage < 20 %}
{% else %} <span class="badge badge-warning">⚠ {{ pl.percentage }}%</span>
{{ entry.percentage }}% {% else %}
{{ pl.percentage }}%
{% endif %}
{% if pl.source %}<span class="text-muted">{{ pl.source }}</span>{% endif %}
</span>
{% if pct_logs|length > 1 %}
<button class="btn btn-sm btn-secondary" onclick="openHistModal('pct-modal')">View all ({{ pct_logs|length }}) &rarr;</button>
{% endif %} {% endif %}
</td>
<td data-label="Source" class="text-muted">{{ entry.source or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %} {% else %}
<p class="text-muted" style="margin-bottom:0;">No percentage history yet.</p> <p class="text-muted" style="margin-bottom:0;">No percentage history yet.</p>
@@ -441,5 +406,287 @@ function metaSelectChanged(sel, inputId) {
input.value = sel.value; input.value = sel.value;
} }
} }
// ── Percentage mini chart ─────────────────────────────────────────────────
(function() {
var canvas = document.getElementById('pct-chart');
if (!canvas) return;
var rawLogs = {{ pct_logs_json | safe }};
// pct_logs_json is ordered newest-first; chart wants oldest-first
var logsAsc = rawLogs.slice().reverse();
var vals = logsAsc.map(function(l) { return l.pct; });
var labels = logsAsc.map(function(l) { return l.recorded_at.slice(0, 10); });
if (vals.length < 2) return;
var s = getComputedStyle(document.documentElement);
var lineColor = s.getPropertyValue('--link').trim() || '#2563eb';
var textColor = s.getPropertyValue('--text-muted').trim() || '#6b7280';
var gridColor = s.getPropertyValue('--border').trim() || '#e2e8f0';
var dpr = window.devicePixelRatio || 1;
var W = canvas.offsetWidth || 500, H = 140;
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
var ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
var PAD = {top: 12, right: 16, bottom: 28, left: 36};
var cW = W - PAD.left - PAD.right;
var cH = H - PAD.top - PAD.bottom;
var minV = 0, maxV = 100, range = 100;
function xOf(i) { return PAD.left + (i / (vals.length - 1)) * cW; }
function yOf(v) { return PAD.top + cH - ((v - minV) / range) * cH; }
ctx.lineWidth = 0.5;
[0, 50, 100].forEach(function(v) {
var y = yOf(v);
ctx.strokeStyle = gridColor;
ctx.beginPath(); ctx.moveTo(PAD.left, y); ctx.lineTo(PAD.left + cW, y); ctx.stroke();
ctx.fillStyle = textColor; ctx.font = '10px system-ui'; ctx.textAlign = 'right';
ctx.fillText(v + '%', PAD.left - 4, y + 3);
});
ctx.strokeStyle = lineColor; ctx.lineWidth = 2; ctx.lineJoin = 'round';
ctx.beginPath();
vals.forEach(function(v, i) {
i === 0 ? ctx.moveTo(xOf(i), yOf(v)) : ctx.lineTo(xOf(i), yOf(v));
});
ctx.stroke();
vals.forEach(function(v, i) {
ctx.fillStyle = lineColor;
ctx.beginPath(); ctx.arc(xOf(i), yOf(v), 3, 0, Math.PI * 2); ctx.fill();
if (i === 0 || i === vals.length - 1) {
ctx.fillStyle = textColor; ctx.font = '9px system-ui';
ctx.textAlign = i === 0 ? 'left' : 'right';
ctx.fillText(labels[i], xOf(i), H - 4);
}
});
}());
</script>
<!-- ── History modals ──────────────────────────────────────────────────── -->
<style>
.hist-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
.hist-modal.open { display:flex; }
.hist-modal-box { background:#fff; border-radius:8px; width:min(700px,96vw); max-height:85vh; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 8px 32px rgba(0,0,0,.2); }
.hist-modal-header { display:flex; align-items:center; justify-content:space-between; padding:0.9rem 1.1rem 0.7rem; border-bottom:1px solid var(--border,#e2e8f0); }
.hist-modal-header h3 { margin:0; font-size:1.05rem; }
.hist-modal-close { background:none; border:none; font-size:1.2rem; cursor:pointer; color:var(--text-muted,#6b7280); line-height:1; padding:0.2rem 0.4rem; }
.hist-modal-filters { padding:0.6rem 1.1rem; border-bottom:1px solid var(--border,#e2e8f0); display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; }
.hist-modal-filters:empty { display:none; }
.hist-modal-body { overflow-y:auto; flex:1; padding:0.5rem 0.5rem; }
.hist-modal-footer { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1.1rem; border-top:1px solid var(--border,#e2e8f0); font-size:0.85rem; color:var(--text-muted,#6b7280); }
</style>
<!-- Capacity modal -->
<div id="cap-modal" class="hist-modal" role="dialog" aria-modal="true">
<div class="hist-modal-box">
<div class="hist-modal-header">
<h3>Capacity History</h3>
<button class="hist-modal-close" onclick="closeHistModal('cap-modal')" aria-label="Close"></button>
</div>
<div class="hist-modal-filters">
<label style="font-size:0.875rem;">Year:
<select id="cap-year-filter" onchange="capModal.filter(this.value)" style="margin-left:0.3rem;">
<option value="">All</option>
</select>
</label>
</div>
<div class="hist-modal-body" id="cap-modal-body"></div>
<div class="hist-modal-footer">
<button class="btn btn-sm btn-secondary" id="cap-prev" onclick="capModal.page(-1)"> Prev</button>
<span id="cap-page-info"></span>
<button class="btn btn-sm btn-secondary" id="cap-next" onclick="capModal.page(1)">Next </button>
</div>
</div>
</div>
<!-- Charge modal -->
<div id="chg-modal" class="hist-modal" role="dialog" aria-modal="true">
<div class="hist-modal-box">
<div class="hist-modal-header">
<h3>Charge History</h3>
<button class="hist-modal-close" onclick="closeHistModal('chg-modal')" aria-label="Close"></button>
</div>
<div class="hist-modal-filters">
<label style="font-size:0.875rem;">Year:
<select id="chg-year-filter" onchange="chgModal.filter(this.value)" style="margin-left:0.3rem;">
<option value="">All</option>
</select>
</label>
</div>
<div class="hist-modal-body" id="chg-modal-body"></div>
<div class="hist-modal-footer">
<button class="btn btn-sm btn-secondary" id="chg-prev" onclick="chgModal.page(-1)"> Prev</button>
<span id="chg-page-info"></span>
<button class="btn btn-sm btn-secondary" id="chg-next" onclick="chgModal.page(1)">Next </button>
</div>
</div>
</div>
<!-- Percentage modal -->
<div id="pct-modal" class="hist-modal" role="dialog" aria-modal="true">
<div class="hist-modal-box">
<div class="hist-modal-header">
<h3>Percentage History</h3>
<button class="hist-modal-close" onclick="closeHistModal('pct-modal')" aria-label="Close"></button>
</div>
<div class="hist-modal-filters"></div>
<div class="hist-modal-body" id="pct-modal-body"></div>
<div class="hist-modal-footer">
<button class="btn btn-sm btn-secondary" id="pct-prev" onclick="pctModal.page(-1)"> Prev</button>
<span id="pct-page-info"></span>
<button class="btn btn-sm btn-secondary" id="pct-next" onclick="pctModal.page(1)">Next </button>
</div>
</div>
</div>
<script>
var HIST_PAGE = 20;
var _batteryId = {{ battery.id }};
function openHistModal(id) {
document.getElementById(id).classList.add('open');
}
function closeHistModal(id) {
document.getElementById(id).classList.remove('open');
}
// Close on backdrop click or Escape
document.querySelectorAll('.hist-modal').forEach(function(m) {
m.addEventListener('click', function(e) { if (e.target === m) m.classList.remove('open'); });
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') document.querySelectorAll('.hist-modal.open').forEach(function(m) { m.classList.remove('open'); });
});
function makeModal(cfg) {
// cfg: { all, bodyId, prevId, nextId, pageInfoId, yearSelectId, renderRow, dateKey }
var filtered = cfg.all.slice();
var currentPage = 0;
function getYears() {
var seen = {};
cfg.all.forEach(function(r) { if (r[cfg.dateKey]) seen[r[cfg.dateKey].slice(0,4)] = 1; });
return Object.keys(seen).sort().reverse();
}
function populateYears() {
if (!cfg.yearSelectId) return;
var sel = document.getElementById(cfg.yearSelectId);
if (!sel) return;
// only populate once
if (sel.options.length > 1) return;
getYears().forEach(function(y) {
var o = document.createElement('option'); o.value = y; o.textContent = y; sel.appendChild(o);
});
}
function render() {
var total = filtered.length;
var pages = Math.max(1, Math.ceil(total / HIST_PAGE));
if (currentPage >= pages) currentPage = pages - 1;
var start = currentPage * HIST_PAGE;
var slice = filtered.slice(start, start + HIST_PAGE);
var html = '<div class="table-wrap"><table class="responsive-table"><thead>' + cfg.thead + '</thead><tbody>';
if (slice.length === 0) {
html += '<tr><td colspan="10" class="text-muted" style="text-align:center;padding:1rem;">No records.</td></tr>';
} else {
slice.forEach(function(r) { html += cfg.renderRow(r); });
}
html += '</tbody></table></div>';
document.getElementById(cfg.bodyId).innerHTML = html;
document.getElementById(cfg.pageInfoId).textContent =
total === 0 ? 'No records' : 'Page ' + (currentPage + 1) + ' of ' + pages + ' (' + total + ' total)';
document.getElementById(cfg.prevId).disabled = currentPage === 0;
document.getElementById(cfg.nextId).disabled = currentPage >= pages - 1;
}
return {
init: function() { populateYears(); filtered = cfg.all.slice(); currentPage = 0; render(); },
filter: function(year) {
filtered = year ? cfg.all.filter(function(r) { return r[cfg.dateKey] && r[cfg.dateKey].slice(0,4) === year; }) : cfg.all.slice();
currentPage = 0; render();
},
page: function(delta) { currentPage += delta; render(); }
};
}
// ── Capacity modal ────────────────────────────────────────────────────────
var _capAll = {{ capacity_tests_json | safe }};
var capModal = makeModal({
all: _capAll,
bodyId: 'cap-modal-body', prevId: 'cap-prev', nextId: 'cap-next', pageInfoId: 'cap-page-info',
yearSelectId: 'cap-year-filter', dateKey: 'date',
thead: '<tr><th>Date</th><th>Capacity</th><th>Notes</th><th></th></tr>',
renderRow: function(r) {
return '<tr>' +
'<td data-label="Date">' + r.date + '</td>' +
'<td data-label="Capacity">' + r.mah + ' mAh</td>' +
'<td data-label="Notes" class="text-muted">' + (r.notes || '—') + '</td>' +
'<td data-label="">' +
'<form class="inline" method="post" ' +
'action="/battery/' + _batteryId + '/capacity-test/' + r.id + '/delete" ' +
'data-confirm="Delete this test record?" data-confirm-ok="Delete">' +
'<button class="btn btn-sm btn-danger" type="submit">Delete</button>' +
'</form>' +
'</td></tr>';
}
});
document.getElementById('cap-modal').addEventListener('transitionend', function() {});
document.querySelector('#cap-modal .hist-modal-close').closest('.hist-modal').addEventListener('click', function(){});
// Init on open
document.querySelector('[onclick="openHistModal(\'cap-modal\')"]') &&
document.querySelector('[onclick="openHistModal(\'cap-modal\')"]').addEventListener('click', function() { capModal.init(); });
// ── Charge modal ──────────────────────────────────────────────────────────
var _chgAll = {{ charge_logs_json | safe }};
var chgModal = makeModal({
all: _chgAll,
bodyId: 'chg-modal-body', prevId: 'chg-prev', nextId: 'chg-next', pageInfoId: 'chg-page-info',
yearSelectId: 'chg-year-filter', dateKey: 'date',
thead: '<tr><th>Date</th><th>+Cycle</th><th>Notes</th><th></th></tr>',
renderRow: function(r) {
return '<tr>' +
'<td data-label="Date">' + r.date + '</td>' +
'<td data-label="+Cycle">' + (r.cycles ? '✓' : '—') + '</td>' +
'<td data-label="Notes" class="text-muted">' + (r.notes || '—') + '</td>' +
'<td data-label="">' +
'<form class="inline" method="post" ' +
'action="/battery/' + _batteryId + '/charge-log/' + r.id + '/delete" ' +
'data-confirm="Delete this charge log entry?" data-confirm-ok="Delete">' +
'<button class="btn btn-sm btn-danger" type="submit">Delete</button>' +
'</form>' +
'</td></tr>';
}
});
document.querySelector('[onclick="openHistModal(\'chg-modal\')"]') &&
document.querySelector('[onclick="openHistModal(\'chg-modal\')"]').addEventListener('click', function() { chgModal.init(); });
// ── Percentage modal ──────────────────────────────────────────────────────
var _pctAll = {{ pct_logs_json | safe }};
var pctModal = makeModal({
all: _pctAll,
bodyId: 'pct-modal-body', prevId: 'pct-prev', nextId: 'pct-next', pageInfoId: 'pct-page-info',
yearSelectId: null, dateKey: 'recorded_at',
thead: '<tr><th>Date / Time</th><th>%</th><th>Source</th></tr>',
renderRow: function(r) {
var pctHtml = r.pct < 20
? '<span class="badge badge-warning">⚠ ' + r.pct + '%</span>'
: r.pct + '%';
return '<tr>' +
'<td data-label="Date / Time">' + r.recorded_at + '</td>' +
'<td data-label="%">' + pctHtml + '</td>' +
'<td data-label="Source" class="text-muted">' + (r.source || '—') + '</td>' +
'</tr>';
}
});
document.querySelector('[onclick="openHistModal(\'pct-modal\')"]') &&
document.querySelector('[onclick="openHistModal(\'pct-modal\')"]').addEventListener('click', function() { pctModal.init(); });
</script> </script>
{% endblock %} {% endblock %}
+7
View File
@@ -37,6 +37,13 @@
{% endif %} {% endif %}
</div> </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 %}
<div class="card"> <div class="card">
<div style="display:flex;justify-content:flex-end;margin-bottom:0.5rem;"> <div style="display:flex;justify-content:flex-end;margin-bottom:0.5rem;">
<div style="position:relative;"> <div style="position:relative;">