diff --git a/app.py b/app.py index 16054c0..b648ea0 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/templates/battery_detail.html b/templates/battery_detail.html index d17c321..c7bfef3 100644 --- a/templates/battery_detail.html +++ b/templates/battery_detail.html @@ -98,43 +98,25 @@ width="500" height="140"> {% endif %} - {% if capacity_tests %} -
- - - - - - {% if battery.capacity_mah %}{% endif %} - - - - - - {% for t in capacity_tests|sort(attribute='tested_date', reverse=True) %} - - - - {% 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 %} - - {% endif %} - - - - {% endfor %} - -
DateCapacityHealthNotes
{{ t.tested_date }}{{ t.tested_capacity_mah }} mAh{{ pct }}%{{ t.notes or '—' }} -
- -
-
-
+ {% set cap_sorted = capacity_tests|sort(attribute='tested_date', reverse=True) %} + {% if cap_sorted %} + {% set t = cap_sorted[0] %} +
+ + {{ t.tested_date }} — {{ 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 %} + {{ pct }}% + {% endif %} + {% if t.notes %}— {{ t.notes }}{% endif %} + + {% if cap_sorted|length > 1 %} + + {% endif %} +
{% else %}

No test records yet.

{% endif %} @@ -165,34 +147,17 @@

Charge History

{% if charge_logs %} -
- - - - - - - - - - - {% for log in charge_logs %} - - - - - - - {% endfor %} - -
Date+CycleNotes
{{ log.charged_date }}{{ '✓' if log.increment_cycles else '—' }}{{ log.notes or '—' }} -
- -
-
-
+ {% set cl = charge_logs[0] %} +
+ + {{ cl.charged_date }} + {% if cl.increment_cycles %}+cycle{% endif %} + {% if cl.notes %}— {{ cl.notes }}{% endif %} + + {% if charge_logs|length > 1 %} + + {% endif %} +
{% else %}

No charge log entries yet.

{% endif %} @@ -223,29 +188,29 @@

Percentage History

+ + {% if pct_logs|length >= 2 %} + + {% endif %} + {% if pct_logs %} -
- - - - - - {% for entry in pct_logs %} - - - - - - {% endfor %} - -
Date / Time%Source
{{ entry.recorded_at }} - {% if entry.percentage < 20 %} - ⚠ {{ entry.percentage }}% - {% else %} - {{ entry.percentage }}% - {% endif %} - {{ entry.source or '—' }}
-
+ {% set pl = pct_logs[0] %} +
+ + {{ pl.recorded_at }} — + {% if pl.percentage < 20 %} + ⚠ {{ pl.percentage }}% + {% else %} + {{ pl.percentage }}% + {% endif %} + {% if pl.source %}{{ pl.source }}{% endif %} + + {% if pct_logs|length > 1 %} + + {% endif %} +
{% else %}

No percentage history yet.

{% endif %} @@ -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); + } + }); +}()); + + + + + + + + + + + + + + + {% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index 687be08..18fe9ee 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -37,6 +37,13 @@ {% endif %}
+{% if total_charges %} +
+ Charged {{ total_charges }}× total + {{ charges_last_year }}× in last year +
+{% endif %} +