Add capacity test history and chart to battery detail

- New CapacityTest model (battery_id FK CASCADE, mah, date, notes)
- DB migration: create capacity_test table, migrate existing single-test data
- Two new routes: add and delete capacity test records
- Battery.tested_capacity_mah/tested_date kept in sync with latest test
  so dashboard display requires no changes
- Battery detail: Capacity History card with sortable table, health %
  per reading, and a canvas line chart (shown when >= 2 records)
- Chart uses CSS variables for colors — works in light and dark mode
- Remove tested_capacity_mah/tested_date from Edit Details form
- 3 new acceptance tests (48 total)
This commit is contained in:
2026-04-13 04:15:55 -05:00
parent 86fb342b0d
commit 2f8a8a2b77
4 changed files with 239 additions and 16 deletions
+134 -12
View File
@@ -76,6 +76,78 @@
</table>
</div>
<!-- Capacity History -->
<div class="card">
<h2>Capacity History</h2>
{% if capacity_tests|length >= 2 %}
<canvas id="capacity-chart"
style="width:100%;max-width:500px;height:140px;display:block;margin-bottom:1rem;"
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>
{% 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>
{% 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) }}"
onsubmit="return confirm('Delete this test record?')">
<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>
{% endif %}
<h3 style="font-size:1rem;margin:1rem 0 0.5rem;color:var(--text-h2);">Add Test Record</h3>
<form method="post" action="{{ url_for('battery_capacity_test_add', battery_id=battery.id) }}"
style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
<div class="form-group" style="margin:0;flex:1;min-width:120px;">
<label>Capacity (mAh)</label>
<input type="number" name="tested_capacity_mah" min="1" placeholder="e.g. 1850" required>
</div>
<div class="form-group" style="margin:0;flex:1;min-width:140px;">
<label>Date</label>
<input type="date" name="tested_date" required>
</div>
<div class="form-group" style="margin:0;flex:2;min-width:160px;">
<label>Notes (optional)</label>
<input type="text" name="notes" placeholder="e.g. after 50 cycles">
</div>
<div style="padding-bottom:1rem;">
<button class="btn btn-primary" type="submit">Add</button>
</div>
</form>
</div>
<!-- Edit Details -->
<div class="card">
<h2>Edit Details</h2>
@@ -123,18 +195,6 @@
value="{{ battery.charge_cycles or '' }}" placeholder="e.g. 50">
</div>
<div class="form-group">
<label for="tested_capacity_mah">Tested Capacity (mAh)</label>
<input type="number" id="tested_capacity_mah" name="tested_capacity_mah" min="0"
value="{{ battery.tested_capacity_mah or '' }}" placeholder="e.g. 1850">
</div>
<div class="form-group">
<label for="tested_date">Test Date</label>
<input type="date" id="tested_date" name="tested_date"
value="{{ battery.tested_date or '' }}">
</div>
<div class="form-group">
<label for="purchase_date">Purchase Date</label>
<input type="date" id="purchase_date" name="purchase_date"
@@ -198,6 +258,68 @@
<a class="text-muted" href="{{ url_for('dashboard') }}">&larr; Back to Dashboard</a>
<script>
(function() {
var canvas = document.getElementById('capacity-chart');
if (!canvas) return;
var tests = {{ capacity_tests | map(attribute='tested_capacity_mah') | list | tojson }};
var labels = {{ capacity_tests | map(attribute='tested_date') | list | tojson }};
if (tests.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: 52};
var cW = W - PAD.left - PAD.right;
var cH = H - PAD.top - PAD.bottom;
var minV = Math.min.apply(null, tests), maxV = Math.max.apply(null, tests);
var pad = (maxV - minV || 100) * 0.1;
minV -= pad; maxV += pad;
var range = maxV - minV;
function xOf(i) { return PAD.left + (i / (tests.length - 1)) * cW; }
function yOf(v) { return PAD.top + cH - ((v - minV) / range) * cH; }
// Horizontal grid lines
ctx.lineWidth = 0.5;
[0, 0.5, 1].forEach(function(t) {
var y = PAD.top + cH * (1 - t);
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(Math.round(minV + t * range) + '', PAD.left - 4, y + 3);
});
// Line
ctx.strokeStyle = lineColor; ctx.lineWidth = 2; ctx.lineJoin = 'round';
ctx.beginPath();
tests.forEach(function(v, i) {
i === 0 ? ctx.moveTo(xOf(i), yOf(v)) : ctx.lineTo(xOf(i), yOf(v));
});
ctx.stroke();
// Dots + date labels (first and last only)
tests.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 === tests.length - 1) {
ctx.fillStyle = textColor; ctx.font = '9px system-ui';
ctx.textAlign = i === 0 ? 'left' : 'right';
ctx.fillText(labels[i], xOf(i), H - 4);
}
});
}());
function metaSelectChanged(sel, inputId) {
var input = document.getElementById(inputId);
if (sel.value === '__new__') {