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.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
@@ -61,10 +62,17 @@ 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()
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,
storage_locations=storage_locations, devices=devices,
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
@@ -152,11 +160,26 @@ def create_app(config_object="config"):
.filter_by(battery_id=battery_id)
.order_by(BatteryPctLog.recorded_at.desc())
.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,
storage_locations=storage_locations,
capacity_tests=capacity_tests,
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
+325 -78
View File
@@ -98,42 +98,24 @@
width="500" height="140"></canvas>
{% endif %}
{% if capacity_tests %}
<div class="table-wrap">
<table class="responsive-table">
<thead>
<tr>
<th>Date</th>
<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>
{% set cap_sorted = capacity_tests|sort(attribute='tested_date', reverse=True) %}
{% if cap_sorted %}
{% set t = cap_sorted[0] %}
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;">
<span>
<strong>{{ t.tested_date }}</strong> — {{ t.tested_capacity_mah }} mAh
{% if battery.capacity_mah %}
{% set pct = (t.tested_capacity_mah / battery.capacity_mah * 100)|round|int %}
{% if pct >= 80 %}{% set hc = "health-good" %}
{% elif pct >= 60 %}{% set hc = "health-warn" %}
{% 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 %}
<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>
{% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No test records yet.</p>
@@ -165,33 +147,16 @@
<h2>Charge History</h2>
{% if charge_logs %}
<div class="table-wrap">
<table class="responsive-table">
<thead>
<tr>
<th>Date</th>
<th>+Cycle</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<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>
{% set cl = charge_logs[0] %}
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;">
<span>
<strong>{{ cl.charged_date }}</strong>
{% if cl.increment_cycles %}<span class="text-muted">+cycle</span>{% endif %}
{% if cl.notes %}<span class="text-muted">— {{ cl.notes }}</span>{% endif %}
</span>
{% if charge_logs|length > 1 %}
<button class="btn btn-sm btn-secondary" onclick="openHistModal('chg-modal')">View all ({{ charge_logs|length }}) &rarr;</button>
{% endif %}
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No charge log entries yet.</p>
@@ -223,28 +188,28 @@
<!-- Percentage History -->
<div class="card">
<h2>Percentage History</h2>
{% if pct_logs %}
<div class="table-wrap">
<table class="responsive-table">
<thead>
<tr><th>Date / Time</th><th>%</th><th>Source</th></tr>
</thead>
<tbody>
{% for entry in pct_logs %}
<tr>
<td data-label="Date / Time">{{ entry.recorded_at }}</td>
<td data-label="%">
{% if entry.percentage < 20 %}
<span class="badge badge-warning">⚠ {{ entry.percentage }}%</span>
{% else %}
{{ entry.percentage }}%
{% if pct_logs|length >= 2 %}
<canvas id="pct-chart"
style="width:100%;max-width:500px;height:140px;display:block;margin-bottom:1rem;"
width="500" height="140"></canvas>
{% endif %}
{% if pct_logs %}
{% set pl = pct_logs[0] %}
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:0.5rem;">
<span>
<strong>{{ pl.recorded_at }}</strong>
{% if pl.percentage < 20 %}
<span class="badge badge-warning">⚠ {{ pl.percentage }}%</span>
{% 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 %}
</td>
<td data-label="Source" class="text-muted">{{ entry.source or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<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;
}
}
// ── 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>
{% endblock %}
+7
View File
@@ -37,6 +37,13 @@
{% endif %}
</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 style="display:flex;justify-content:flex-end;margin-bottom:0.5rem;">
<div style="position:relative;">