39b52a3fa4
- base.html: add CSS/HTML/JS for styled in-app confirmation modal (dark-mode compatible via CSS vars) - device_list, battery_detail: convert onsubmit confirm() to declarative data-confirm attributes - dashboard: convert bulk Delete/Install buttons to use modal helpers (submitWithAction pattern) - app.py: pass brand_counts dict to battery_add template - battery_add.html: show live "Will create: Brand 001 → Brand 003" preview as brand/quantity change - tests: add two tests covering brand_counts server-side rendering
396 lines
16 KiB
HTML
396 lines
16 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}{{ battery.label }} — Battery Tracker{% endblock %}
|
||
|
||
{% block content %}
|
||
<h1>{{ battery.label }}</h1>
|
||
|
||
{% macro meta_row(label, value) %}
|
||
{% if value %}
|
||
<tr>
|
||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">{{ label }}</td>
|
||
<td style="border:none;">{{ value }}</td>
|
||
</tr>
|
||
{% endif %}
|
||
{% endmacro %}
|
||
|
||
<div class="card">
|
||
<table style="width:auto;border:none;">
|
||
<tr>
|
||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Label</td>
|
||
<td style="border:none;">{{ battery.label }}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Brand</td>
|
||
<td style="border:none;">{{ battery.brand }}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Status</td>
|
||
<td style="border:none;"><span class="badge badge-{{ battery.status }}">{{ battery.status|capitalize }}</span></td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Device</td>
|
||
<td style="border:none;">
|
||
{% if battery.device %}
|
||
<a href="{{ url_for('device_detail', device_id=battery.device.id) }}">{{ battery.device.name }}</a>
|
||
{% else %}
|
||
<span class="text-muted">None</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{{ meta_row("Size", battery.size) }}
|
||
{{ meta_row("Chemistry", battery.chemistry) }}
|
||
{% if battery.capacity_mah %}
|
||
<tr>
|
||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Capacity</td>
|
||
<td style="border:none;">
|
||
{{ battery.capacity_mah }} mAh
|
||
{% if battery.tested_capacity_mah %}
|
||
{% set pct = (battery.tested_capacity_mah / battery.capacity_mah * 100)|round|int %}
|
||
{% if pct >= 80 %}{% set health_class = "health-good" %}
|
||
{% elif pct >= 60 %}{% set health_class = "health-warn" %}
|
||
{% else %}{% set health_class = "health-bad" %}
|
||
{% endif %}
|
||
→ tested <strong class="{{ health_class }}">{{ battery.tested_capacity_mah }} mAh ({{ pct }}%)</strong>
|
||
{% if battery.tested_date %}<span class="text-muted">on {{ battery.tested_date }}</span>{% endif %}
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% elif battery.tested_capacity_mah %}
|
||
<tr>
|
||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Tested Capacity</td>
|
||
<td style="border:none;">
|
||
{{ battery.tested_capacity_mah }} mAh
|
||
{% if battery.tested_date %}<span class="text-muted">on {{ battery.tested_date }}</span>{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endif %}
|
||
{{ meta_row("Charge Cycles", battery.charge_cycles) }}
|
||
{{ meta_row("Purchase Date", battery.purchase_date) }}
|
||
{{ meta_row("Storage", battery.storage_location) }}
|
||
{% if battery.notes %}
|
||
<tr>
|
||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
|
||
<td style="border:none;">{{ battery.notes }}</td>
|
||
</tr>
|
||
{% endif %}
|
||
</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) }}"
|
||
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>
|
||
{% 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>
|
||
|
||
<!-- Charge History -->
|
||
<div class="card">
|
||
<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>
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted" style="margin-bottom:0.75rem;">No charge log entries yet.</p>
|
||
{% endif %}
|
||
|
||
<h3 style="font-size:1rem;margin:1rem 0 0.5rem;color:var(--text-h2);">Add Charge Entry</h3>
|
||
<form method="post" action="{{ url_for('battery_charge_log_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:140px;">
|
||
<label>Date</label>
|
||
<input type="date" name="charged_date" required>
|
||
</div>
|
||
<div class="form-group" style="margin:0;align-self:flex-end;padding-bottom:1rem;">
|
||
<label style="display:flex;align-items:center;gap:0.4rem;font-weight:normal;cursor:pointer;">
|
||
<input type="checkbox" name="increment_cycles" value="1" checked>
|
||
Increment charge cycles
|
||
</label>
|
||
</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. trickle charge overnight">
|
||
</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>
|
||
<form method="post" action="{{ url_for('battery_edit_details', battery_id=battery.id) }}">
|
||
|
||
<div class="form-grid-2col" style="display:grid;grid-template-columns:1fr 1fr;gap:0 1rem;">
|
||
|
||
<div class="form-group">
|
||
<label>Size</label>
|
||
<select id="size-select" onchange="metaSelectChanged(this,'size')">
|
||
<option value="">— none —</option>
|
||
{% for opt in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}
|
||
<option value="{{ opt }}" {% if battery.size == opt %}selected{% endif %}>{{ opt }}</option>
|
||
{% endfor %}
|
||
<option value="__new__" {% if battery.size and battery.size not in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}selected{% endif %}>Other…</option>
|
||
</select>
|
||
<input type="text" id="size" name="size" value="{{ battery.size or '' }}"
|
||
placeholder="Enter size"
|
||
style="display:{% if battery.size and battery.size not in ['AA','AAA','C','D','9V','18650','21700','14500','26650','CR2032','CR123A'] %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Chemistry</label>
|
||
<select id="chemistry-select" onchange="metaSelectChanged(this,'chemistry')">
|
||
<option value="">— none —</option>
|
||
{% for opt in ['NiMH','Alkaline','Li-ion','LiFePO4','NiCd','Zinc-Carbon','Li-MnO2'] %}
|
||
<option value="{{ opt }}" {% if battery.chemistry == opt %}selected{% endif %}>{{ opt }}</option>
|
||
{% endfor %}
|
||
<option value="__new__" {% if battery.chemistry and battery.chemistry not in ['NiMH','Alkaline','Li-ion','LiFePO4','NiCd','Zinc-Carbon','Li-MnO2'] %}selected{% endif %}>Other…</option>
|
||
</select>
|
||
<input type="text" id="chemistry" name="chemistry" value="{{ battery.chemistry or '' }}"
|
||
placeholder="Enter chemistry"
|
||
style="display:{% if battery.chemistry and battery.chemistry not in ['NiMH','Alkaline','Li-ion','LiFePO4','NiCd','Zinc-Carbon','Li-MnO2'] %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="capacity_mah">Capacity (mAh)</label>
|
||
<input type="number" id="capacity_mah" name="capacity_mah" min="0"
|
||
value="{{ battery.capacity_mah or '' }}" placeholder="e.g. 2000">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="charge_cycles">Charge Cycles</label>
|
||
<input type="number" id="charge_cycles" name="charge_cycles" min="0"
|
||
value="{{ battery.charge_cycles or '' }}" placeholder="e.g. 50">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="purchase_date">Purchase Date</label>
|
||
<input type="date" id="purchase_date" name="purchase_date"
|
||
value="{{ battery.purchase_date or '' }}">
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Storage Location</label>
|
||
<select id="storage-select" onchange="metaSelectChanged(this,'storage_location')">
|
||
<option value="">— none —</option>
|
||
{% for loc in storage_locations|default([]) %}
|
||
<option value="{{ loc }}" {% if battery.storage_location == loc %}selected{% endif %}>{{ loc }}</option>
|
||
{% endfor %}
|
||
<option value="__new__" {% if battery.storage_location and battery.storage_location not in storage_locations|default([]) %}selected{% endif %}>➕ New location…</option>
|
||
</select>
|
||
<input type="text" id="storage_location" name="storage_location"
|
||
value="{{ battery.storage_location or '' }}"
|
||
placeholder="e.g. Drawer 2, Toolbox, Shelf A"
|
||
style="display:{% if battery.storage_location and battery.storage_location not in storage_locations|default([]) %}''{% else %}none{% endif %};margin-top:0.4rem;">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="notes">Notes</label>
|
||
<textarea id="notes" name="notes" placeholder="No notes yet…">{{ battery.notes or '' }}</textarea>
|
||
</div>
|
||
|
||
<button class="btn btn-primary" type="submit">Save Details</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="card">
|
||
<h2>Actions</h2>
|
||
<div class="form-actions">
|
||
{% if battery.is_available() %}
|
||
<a class="btn btn-primary" href="{{ url_for('battery_assign', battery_id=battery.id) }}">Assign to Device</a>
|
||
{% endif %}
|
||
|
||
{% if battery.is_installed() %}
|
||
<form method="post" action="{{ url_for('battery_unassign', battery_id=battery.id) }}">
|
||
<button class="btn btn-warning" type="submit">Unassign</button>
|
||
</form>
|
||
{% endif %}
|
||
|
||
{% if not battery.is_retired() %}
|
||
<form method="post" action="{{ url_for('battery_retire', battery_id=battery.id) }}">
|
||
<button class="btn btn-secondary" type="submit">Retire Battery</button>
|
||
</form>
|
||
{% else %}
|
||
<form method="post" action="{{ url_for('battery_unretire', battery_id=battery.id) }}">
|
||
<button class="btn btn-secondary" type="submit">Unretire Battery</button>
|
||
</form>
|
||
{% endif %}
|
||
|
||
<a class="btn btn-danger" href="{{ url_for('battery_delete', battery_id=battery.id) }}">Delete Battery</a>
|
||
</div>
|
||
</div>
|
||
|
||
<a class="text-muted" href="{{ url_for('dashboard') }}">← 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__') {
|
||
input.style.display = '';
|
||
input.value = '';
|
||
input.focus();
|
||
} else {
|
||
input.style.display = 'none';
|
||
input.value = sel.value;
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|