FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2), caches AER Consumer Data Right electricity plan data, and compares actual metered usage against every available plan to estimate real annual cost (TOU, tiered feed-in, and flat tariffs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
112 lines
4.7 KiB
HTML
112 lines
4.7 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<h1>Plan Comparison</h1>
|
|
|
|
<div class="card" style="display:flex; justify-content:space-between; align-items:center;">
|
|
<span class="muted">Estimated cost based on your actual usage over the selected period.</span>
|
|
<select id="range-select">
|
|
<option value="30">Last 30 days</option>
|
|
<option value="90" selected>Last 90 days</option>
|
|
<option value="365">Last 365 days</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<p id="plans-caption" class="muted"></p>
|
|
<table id="plans-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Retailer</th>
|
|
<th>Plan</th>
|
|
<th>Est. annual cost</th>
|
|
<th>Savings vs current</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="plans-tbody">
|
|
<tr><td colspan="5" class="muted">Loading…</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<script>
|
|
function fmtMoney(v) {
|
|
if (v === null || v === undefined) return '—';
|
|
return '$' + v.toFixed(2);
|
|
}
|
|
|
|
function renderBreakdown(row) {
|
|
const usage = row.usage_breakdown.map(c => `${c.label}: ${c.kwh.toFixed(1)} kWh × rate = $${c.cost_dollars.toFixed(2)}`).join('<br>');
|
|
const feedIn = row.feed_in_breakdown.map(c => `${c.label}: ${c.kwh.toFixed(1)} kWh credited $${(-c.cost_dollars).toFixed(2)}`).join('<br>');
|
|
const notes = row.modeling_notes.length ? `<div class="notes">${row.modeling_notes.join('<br>')}</div>` : '';
|
|
return `
|
|
<div style="font-size:0.85rem;">
|
|
<strong>Usage</strong><br>${usage || '—'}<br>
|
|
<strong>Feed-in credit</strong><br>${feedIn || '—'}<br>
|
|
<strong>Supply charge:</strong> $${row.supply_charge_dollars.toFixed(2)}<br>
|
|
${row.unconditional_discount_dollars ? `<strong>Discount applied:</strong> -$${row.unconditional_discount_dollars.toFixed(2)}<br>` : ''}
|
|
${row.potential_conditional_savings_dollars ? `<strong>Potential extra saving (conditional):</strong> $${row.potential_conditional_savings_dollars.toFixed(2)}<br>` : ''}
|
|
Based on ${row.days_with_data} day(s) of data${row.is_estimate ? ' (annualized estimate)' : ''}.
|
|
${notes}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function loadPlans(days) {
|
|
const { rows, total_plans_considered } = await fetch(`/api/plans/compare?days=${days}`).then(r => r.json());
|
|
const tbody = document.getElementById('plans-tbody');
|
|
if (!rows.length) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="muted">No plans cached yet, and/or no usage data yet. The AER plan cache refreshes automatically in the background.</td></tr>';
|
|
return;
|
|
}
|
|
document.getElementById('plans-caption').textContent =
|
|
total_plans_considered > rows.length
|
|
? `Showing the ${rows.length} cheapest of ${total_plans_considered} plans found for your distributor.`
|
|
: `${total_plans_considered} plan(s) found for your distributor.`;
|
|
|
|
tbody.innerHTML = rows.map((row, i) => {
|
|
const badges = [];
|
|
if (row.is_current) badges.push('<span class="badge current">Current plan</span>');
|
|
if (!row.modelable) badges.push('<span class="badge not-modelable">Not fully modeled</span>');
|
|
|
|
let savingsCell = '—';
|
|
if (row.savings_vs_current_dollars !== null && row.savings_vs_current_dollars !== undefined && !row.is_current) {
|
|
const cls = row.savings_vs_current_dollars >= 0 ? 'savings-positive' : 'savings-negative';
|
|
const sign = row.savings_vs_current_dollars >= 0 ? '+' : '';
|
|
savingsCell = `<span class="${cls}">${sign}${fmtMoney(row.savings_vs_current_dollars)}/yr</span>`;
|
|
}
|
|
|
|
return `
|
|
<tr>
|
|
<td>${row.retailer}</td>
|
|
<td>${row.plan_name} ${badges.join(' ')}</td>
|
|
<td>${fmtMoney(row.annualized_total_dollars)}/yr</td>
|
|
<td>${savingsCell}</td>
|
|
<td><button onclick="document.getElementById('detail-${i}').classList.toggle('open')" style="margin:0; padding:0.3rem 0.6rem; font-size:0.8rem;">Details</button></td>
|
|
</tr>
|
|
<tr id="detail-${i}" class="detail-row" style="display:none;">
|
|
<td colspan="5">${renderBreakdown(row)}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
document.querySelectorAll('.detail-row').forEach(el => {
|
|
el.classList.remove('open');
|
|
});
|
|
// simple toggle via class -> style, since we don't ship extra CSS for it
|
|
document.querySelectorAll('button').forEach(btn => {});
|
|
}
|
|
|
|
// Toggle handler simplified: override the inline onclick approach with real display toggling.
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.tagName === 'BUTTON' && e.target.textContent === 'Details') {
|
|
const row = e.target.closest('tr').nextElementSibling;
|
|
if (row) row.style.display = row.style.display === 'table-row' ? 'none' : 'table-row';
|
|
}
|
|
});
|
|
|
|
const rangeSelect = document.getElementById('range-select');
|
|
rangeSelect.addEventListener('change', () => loadPlans(rangeSelect.value));
|
|
loadPlans(rangeSelect.value);
|
|
</script>
|
|
{% endblock %}
|