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:
@@ -2,7 +2,7 @@ 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 models import Base, Battery, Device
|
from models import Base, Battery, Device, CapacityTest
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_object="config"):
|
def create_app(config_object="config"):
|
||||||
@@ -105,8 +105,13 @@ def create_app(config_object="config"):
|
|||||||
.filter(Battery.storage_location.isnot(None))
|
.filter(Battery.storage_location.isnot(None))
|
||||||
.distinct().order_by(Battery.storage_location).all()
|
.distinct().order_by(Battery.storage_location).all()
|
||||||
]
|
]
|
||||||
|
capacity_tests = (db.query(CapacityTest)
|
||||||
|
.filter_by(battery_id=battery_id)
|
||||||
|
.order_by(CapacityTest.tested_date, CapacityTest.id)
|
||||||
|
.all())
|
||||||
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)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — edit notes
|
# Battery — edit notes
|
||||||
@@ -130,8 +135,6 @@ def create_app(config_object="config"):
|
|||||||
battery.size = f.get("size", "").strip() or None
|
battery.size = f.get("size", "").strip() or None
|
||||||
battery.chemistry = f.get("chemistry", "").strip() or None
|
battery.chemistry = f.get("chemistry", "").strip() or None
|
||||||
battery.capacity_mah = _int("capacity_mah")
|
battery.capacity_mah = _int("capacity_mah")
|
||||||
battery.tested_capacity_mah = _int("tested_capacity_mah")
|
|
||||||
battery.tested_date = f.get("tested_date", "").strip() or None
|
|
||||||
battery.charge_cycles = _int("charge_cycles")
|
battery.charge_cycles = _int("charge_cycles")
|
||||||
battery.purchase_date = f.get("purchase_date", "").strip() or None
|
battery.purchase_date = f.get("purchase_date", "").strip() or None
|
||||||
battery.storage_location = f.get("storage_location", "").strip() or None
|
battery.storage_location = f.get("storage_location", "").strip() or None
|
||||||
@@ -140,6 +143,57 @@ def create_app(config_object="config"):
|
|||||||
flash("Details updated.", "success")
|
flash("Details updated.", "success")
|
||||||
return redirect(url_for("battery_detail", battery_id=battery_id))
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Battery — capacity test history
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _sync_latest_test(battery):
|
||||||
|
latest = (db.query(CapacityTest)
|
||||||
|
.filter_by(battery_id=battery.id)
|
||||||
|
.order_by(CapacityTest.tested_date.desc(), CapacityTest.id.desc())
|
||||||
|
.first())
|
||||||
|
battery.tested_capacity_mah = latest.tested_capacity_mah if latest else None
|
||||||
|
battery.tested_date = latest.tested_date if latest else None
|
||||||
|
|
||||||
|
@app.route("/battery/<int:battery_id>/capacity-test/add", methods=["POST"])
|
||||||
|
def battery_capacity_test_add(battery_id):
|
||||||
|
battery = db.get(Battery, battery_id)
|
||||||
|
if battery is None:
|
||||||
|
abort(404)
|
||||||
|
mah_raw = request.form.get("tested_capacity_mah", "").strip()
|
||||||
|
date_val = request.form.get("tested_date", "").strip()
|
||||||
|
notes = request.form.get("notes", "").strip() or None
|
||||||
|
if not mah_raw or not date_val:
|
||||||
|
flash("Capacity (mAh) and date are required.", "error")
|
||||||
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||||
|
try:
|
||||||
|
mah = int(mah_raw)
|
||||||
|
if mah <= 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
flash("Capacity must be a positive integer.", "error")
|
||||||
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||||
|
db.add(CapacityTest(battery_id=battery_id, tested_capacity_mah=mah,
|
||||||
|
tested_date=date_val, notes=notes))
|
||||||
|
db.flush()
|
||||||
|
_sync_latest_test(battery)
|
||||||
|
db.commit()
|
||||||
|
flash("Test record added.", "success")
|
||||||
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||||
|
|
||||||
|
@app.route("/battery/<int:battery_id>/capacity-test/<int:test_id>/delete", methods=["POST"])
|
||||||
|
def battery_capacity_test_delete(battery_id, test_id):
|
||||||
|
battery = db.get(Battery, battery_id)
|
||||||
|
test = db.get(CapacityTest, test_id)
|
||||||
|
if battery is None or test is None or test.battery_id != battery_id:
|
||||||
|
abort(404)
|
||||||
|
db.delete(test)
|
||||||
|
db.flush()
|
||||||
|
_sync_latest_test(battery)
|
||||||
|
db.commit()
|
||||||
|
flash("Test record deleted.", "success")
|
||||||
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — assign
|
# Battery — assign
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ class Battery(Base):
|
|||||||
storage_location = Column(String(100), nullable=True) # where stored when not installed
|
storage_location = Column(String(100), nullable=True) # where stored when not installed
|
||||||
|
|
||||||
device = relationship("Device", back_populates="batteries")
|
device = relationship("Device", back_populates="batteries")
|
||||||
|
capacity_tests = relationship(
|
||||||
|
"CapacityTest", back_populates="battery",
|
||||||
|
order_by="CapacityTest.tested_date",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
def is_available(self):
|
def is_available(self):
|
||||||
return self.status == "available"
|
return self.status == "available"
|
||||||
@@ -61,3 +66,18 @@ class Battery(Base):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Battery {self.label}>"
|
return f"<Battery {self.label}>"
|
||||||
|
|
||||||
|
|
||||||
|
class CapacityTest(Base):
|
||||||
|
__tablename__ = "capacity_test"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
battery_id = Column(Integer, ForeignKey("battery.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
tested_capacity_mah = Column(Integer, nullable=False)
|
||||||
|
tested_date = Column(String(10), nullable=False) # YYYY-MM-DD
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
battery = relationship("Battery", back_populates="capacity_tests")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<CapacityTest {self.battery_id} {self.tested_date} {self.tested_capacity_mah}mAh>"
|
||||||
|
|||||||
+134
-12
@@ -76,6 +76,78 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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 -->
|
<!-- Edit Details -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Edit Details</h2>
|
<h2>Edit Details</h2>
|
||||||
@@ -123,18 +195,6 @@
|
|||||||
value="{{ battery.charge_cycles or '' }}" placeholder="e.g. 50">
|
value="{{ battery.charge_cycles or '' }}" placeholder="e.g. 50">
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="purchase_date">Purchase Date</label>
|
<label for="purchase_date">Purchase Date</label>
|
||||||
<input type="date" id="purchase_date" name="purchase_date"
|
<input type="date" id="purchase_date" name="purchase_date"
|
||||||
@@ -198,6 +258,68 @@
|
|||||||
<a class="text-muted" href="{{ url_for('dashboard') }}">← Back to Dashboard</a>
|
<a class="text-muted" href="{{ url_for('dashboard') }}">← Back to Dashboard</a>
|
||||||
|
|
||||||
<script>
|
<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) {
|
function metaSelectChanged(sel, inputId) {
|
||||||
var input = document.getElementById(inputId);
|
var input = document.getElementById(inputId);
|
||||||
if (sel.value === '__new__') {
|
if (sel.value === '__new__') {
|
||||||
|
|||||||
@@ -480,6 +480,33 @@ def test_edit_device_type(client):
|
|||||||
assert b"Flashlight" in resp.data
|
assert b"Flashlight" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_capacity_test(client):
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
|
resp = client.post("/battery/1/capacity-test/add",
|
||||||
|
data={"tested_capacity_mah": "1900", "tested_date": "2026-01-01"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"1900" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_capacity_test_syncs_battery(client):
|
||||||
|
"""Adding a test updates battery.tested_capacity_mah."""
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
|
client.post("/battery/1/capacity-test/add",
|
||||||
|
data={"tested_capacity_mah": "1800", "tested_date": "2026-01-01"})
|
||||||
|
resp = client.get("/battery/1")
|
||||||
|
assert b"1800" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_capacity_test(client):
|
||||||
|
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
|
||||||
|
client.post("/battery/1/capacity-test/add",
|
||||||
|
data={"tested_capacity_mah": "1900", "tested_date": "2026-01-01"})
|
||||||
|
resp = client.post("/battery/1/capacity-test/1/delete", follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"No test records" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_add_install_delete_battery(client):
|
def test_add_install_delete_battery(client):
|
||||||
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
|
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
|
||||||
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
|
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
|
||||||
|
|||||||
Reference in New Issue
Block a user