Files

734 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}
&rarr; 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.battery_percentage is not none %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Battery %</td>
<td style="border:none;">
{% if battery.battery_percentage < 20 %}
<span class="badge badge-warning">⚠ {{ battery.battery_percentage }}% — consider replacing</span>
{% else %}
{{ battery.battery_percentage }}%
{% endif %}
</td>
</tr>
{% endif %}
{% 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 %}
{% 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 %}
<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 %}
</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 %}
{% 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>
{% 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>
<!-- Percentage History -->
<div class="card">
<h2>Percentage History</h2>
{% 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 %}
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0;">No percentage history yet.</p>
{% endif %}
</div>
<!-- Logbook -->
<div class="card">
<h2>Logbook</h2>
{% if logbook_entries %}
<div style="display:flex;flex-direction:column;gap:0.6rem;margin-bottom:1rem;">
{% for entry in logbook_entries %}
<div style="border-left:3px solid var(--border);padding:0.35rem 0.75rem;
display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;">
<div>
<div class="text-muted" style="font-size:0.78rem;margin-bottom:0.15rem;">{{ entry.recorded_at }}</div>
<div style="white-space:pre-wrap;">{{ entry.body }}</div>
</div>
<form method="post"
action="{{ url_for('battery_logbook_delete', battery_id=battery.id, entry_id=entry.id) }}"
data-confirm="Delete this logbook entry?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0.75rem;">No logbook entries yet.</p>
{% endif %}
<h3 style="font-size:1rem;margin:0.75rem 0 0.5rem;color:var(--text-h2);">Add Entry</h3>
<form method="post" action="{{ url_for('battery_logbook_add', battery_id=battery.id) }}">
<div class="form-group" style="margin-bottom:0.5rem;">
<textarea name="body" placeholder="Write a note…" rows="2" required style="min-height:60px;"></textarea>
</div>
<button class="btn btn-primary" type="submit">Add Entry</button>
</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 class="form-group">
<label for="battery_percentage">Battery % (optional)</label>
<input type="number" id="battery_percentage" name="battery_percentage" min="0" max="100"
value="{{ battery.battery_percentage if battery.battery_percentage is not none else '' }}"
placeholder="e.g. 85">
</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') }}">&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__') {
input.style.display = '';
input.value = '';
input.focus();
} else {
input.style.display = 'none';
input.value = sel.value;
}
}
// ── Percentage mini chart ─────────────────────────────────────────────────
(function() {
var canvas = document.getElementById('pct-chart');
if (!canvas) return;
var rawLogs = {{ pct_logs_data | tojson }};
// 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:var(--bg-card,#fff); color:var(--text-body,#222); 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; color:var(--text-h2,#334155); }
.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); }
.hist-modal-filters select { background:var(--bg-input,#fff); color:var(--text-body,#222); border:1px solid var(--border-input,#d1d5db); border-radius:4px; padding:0.3rem 0.5rem; font-size:0.875rem; font-family:inherit; }
</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 escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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_data | tojson }};
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">' + escHtml(r.date) + '</td>' +
'<td data-label="Capacity">' + r.mah + ' mAh</td>' +
'<td data-label="Notes" class="text-muted">' + (escHtml(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_data | tojson }};
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">' + escHtml(r.date) + '</td>' +
'<td data-label="+Cycle">' + (r.cycles ? '✓' : '—') + '</td>' +
'<td data-label="Notes" class="text-muted">' + (escHtml(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_data | tojson }};
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">' + escHtml(r.recorded_at) + '</td>' +
'<td data-label="%">' + pctHtml + '</td>' +
'<td data-label="Source" class="text-muted">' + (escHtml(r.source) || '—') + '</td>' +
'</tr>';
}
});
document.querySelector('[onclick="openHistModal(\'pct-modal\')"]') &&
document.querySelector('[onclick="openHistModal(\'pct-modal\')"]').addEventListener('click', function() { pctModal.init(); });
</script>
{% endblock %}