All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 17s
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>
115 lines
4.9 KiB
HTML
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…</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>');
|
|
if (row.modeling_notes.some(n => n.startsWith('Eligibility requirement:'))) {
|
|
badges.push('<span class="badge not-modelable">Eligibility restricted</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 %}
|