diff --git a/app/api/usage.py b/app/api/usage.py index b23b0df..e5e46cc 100644 --- a/app/api/usage.py +++ b/app/api/usage.py @@ -125,8 +125,9 @@ def usage_summary(days: str = Query("30")) -> dict: @router.get("/tou_breakdown") def tou_breakdown(days: str = Query("1")) -> dict: """Cost breakdown by the current plan's own usage components (e.g. Peak / - Off-Peak / Shoulder), so you can see where your spend is actually - landing rather than just the headline total.""" + Off-Peak / Shoulder) and feed-in components (solar export credit), so + you can see where your spend -- and your credit -- is actually landing + rather than just the headline total.""" end = datetime.now(timezone.utc) with get_session() as session: start = _resolve_start(session, days, end) @@ -137,16 +138,28 @@ def tou_breakdown(days: str = Query("1")) -> dict: readings = [IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) for row in rows] result = evaluate(current_plan, readings) - components = [ + usage_components = [ {"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)} for c in result.usage_breakdown if c.kwh > 1e-9 ] + # feed_in_breakdown's cost_dollars is already negative (a credit), same + # sign convention the plan comparison table uses. + feed_in_components = [ + {"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)} + for c in result.feed_in_breakdown + if c.kwh > 1e-9 + ] + total_usage_cost = round(sum(c["cost_dollars"] for c in usage_components), 2) + total_feed_in_credit = round(sum(c["cost_dollars"] for c in feed_in_components), 2) return { "available": True, "days_with_data": result.days_with_data, - "components": sorted(components, key=lambda c: c["cost_dollars"], reverse=True), - "total_usage_cost_dollars": round(sum(c["cost_dollars"] for c in components), 2), + "usage_components": sorted(usage_components, key=lambda c: c["cost_dollars"], reverse=True), + "feed_in_components": sorted(feed_in_components, key=lambda c: c["cost_dollars"]), + "total_usage_cost_dollars": total_usage_cost, + "total_feed_in_credit_dollars": total_feed_in_credit, + "net_usage_and_export_dollars": round(total_usage_cost + total_feed_in_credit, 2), } diff --git a/app/static/css/style.css b/app/static/css/style.css index 2d07903..5417203 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -202,7 +202,9 @@ form.settings-form label.p-day-label input { border-radius: 5px; overflow: hidden; } -.tou-bar-fill { height: 100%; background: var(--accent); border-radius: 5px; } +.tou-bar-fill { height: 100%; border-radius: 5px; } +.tou-bar-fill-cost { background: var(--accent); } +.tou-bar-fill-credit { background: var(--good); } .tou-bar-amount { width: 130px; flex-shrink: 0; text-align: right; } .pill-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index e99d9cc..a8f1fcb 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -262,21 +262,43 @@ async function loadDashboard(days) { }); } +function touBarRows(components, maxAbsCost, fillClass) { + return components.map(c => ` +
+
${c.label}
+
+
${c.cost_dollars < 0 ? '-' : ''}$${Math.abs(c.cost_dollars).toFixed(2)} · ${c.kwh.toFixed(1)} kWh
+
+ `).join(''); +} + async function loadTouBreakdown(days) { const body = document.getElementById('tou-breakdown-body'); const data = await fetch(`/api/usage/tou_breakdown?days=${days}`).then(r => r.json()); - if (!data.available || !data.components.length) { + if (!data.available || (!data.usage_components.length && !data.feed_in_components.length)) { body.innerHTML = '
No usage cost data yet for this period.
'; return; } - const maxCost = Math.max(...data.components.map(c => c.cost_dollars), 0.01); - body.innerHTML = data.components.map(c => ` -
-
${c.label}
-
-
$${c.cost_dollars.toFixed(2)} · ${c.kwh.toFixed(1)} kWh
-
- `).join('') + `
Total usage cost: $${data.total_usage_cost_dollars.toFixed(2)} over ${data.days_with_data} day(s) — excludes supply charge and feed-in credit.
`; + const maxAbsCost = Math.max( + ...data.usage_components.map(c => Math.abs(c.cost_dollars)), + ...data.feed_in_components.map(c => Math.abs(c.cost_dollars)), + 0.01 + ); + + const usageHtml = data.usage_components.length + ? `
Usage cost
${touBarRows(data.usage_components, maxAbsCost, 'tou-bar-fill-cost')}` + : ''; + const feedInHtml = data.feed_in_components.length + ? `
Solar export credit
${touBarRows(data.feed_in_components, maxAbsCost, 'tou-bar-fill-credit')}` + : ''; + + const net = data.net_usage_and_export_dollars; + body.innerHTML = usageHtml + feedInHtml + ` +
+ Usage cost $${data.total_usage_cost_dollars.toFixed(2)} − export credit $${Math.abs(data.total_feed_in_credit_dollars).toFixed(2)} + = ${net >= 0 ? '$' : '-$'}${Math.abs(net).toFixed(2)} net + over ${data.days_with_data} day(s) — excludes supply charge. +
`; } const touToggle = document.getElementById('tou-range-toggle');