power-compare/app/web/templates/plans.html
dlawler489 8cbcd0155f
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 17s
Surface AER eligibility requirements in plan comparisons
Cost modeling can't tell you whether you actually qualify for a plan --
several top-ranked results turned out to require things like buying
solar equipment through a specific installer partner, or being a new
customer only. AER's data states these restrictions explicitly in most
cases; surface them as modeling notes (and a badge in the comparison
table) so they're visible before considering a switch, rather than
only showing up when someone tries to sign up.

Doesn't catch every restriction -- some plans (e.g. concession/seniors
offers) carry no eligibility block in AER's data at all despite an
implied real-world restriction, so absence of the badge isn't a
guarantee of eligibility.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 15:23:50 +10:00

115 lines
4.9 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&hellip;</td></tr>
</tbody>
</table>
</div>
<script>
function fmtMoney(v) {
if (v === null || v === undefined) return '&mdash;';
return '$' + v.toFixed(2);
}
function renderBreakdown(row) {
const usage = row.usage_breakdown.map(c => `${c.label}: ${c.kwh.toFixed(1)} kWh &times; 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 || '&mdash;'}<br>
<strong>Feed-in credit</strong><br>${feedIn || '&mdash;'}<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>');
if (row.modeling_notes.some(n => n.startsWith('Eligibility requirement:'))) {
badges.push('<span class="badge not-modelable">Eligibility restricted</span>');
}
let savingsCell = '&mdash;';
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 %}