power-compare/app/web/templates/settings.html
dlawler489 5ffd91a643
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 25s
Hide OAuth reconnect instructions once already connected
Settings always showed the full "how to connect" steps and code-paste
form for oauth2 mode regardless of connection status, which read as
broken even when the token was live and polling successfully. Collapse
it behind a "Reconnect" toggle once connected.

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

355 lines
15 KiB
HTML

{% extends "base.html" %}
{% block content %}
<h1>Settings</h1>
<div class="card">
<h3>Household</h3>
<form class="settings-form" id="household-form">
<label>Distributor
<select name="distributor">
<option value="Energex">Energex (South East QLD)</option>
<option value="Ergon">Ergon Energy (regional QLD)</option>
</select>
</label>
<label>Postcode <input name="postcode" placeholder="e.g. 4000"></label>
<button type="submit">Save</button>
</form>
</div>
<div class="card">
<h3>iSolarCloud connection</h3>
{% if use_mock_isolarcloud %}
<p class="muted">
Currently running on <strong>synthetic mock data</strong> (<code>USE_MOCK_ISOLARCLOUD=true</code>).
Once you have your App Key, Secret Key and iSolarCloud username/password filled in your
<code>.env</code> (see SETUP.md), set <code>USE_MOCK_ISOLARCLOUD=false</code> and restart,
then test the connection below.
</p>
{% elif isolarcloud_auth_mode == "oauth2" %}
<p id="isolarcloud-status" class="muted">Checking connection status&hellip;</p>
<button type="button" id="oauth-reconnect-toggle" style="display:none;">Reconnect / use a different account</button>
<div id="oauth-connect-block">
<p class="muted">
1. Open the authorization link below in your own browser, log in with your iSolarCloud account, and approve access.<br>
2. You'll land on a page that fails to load (that's expected -- it's an https://localhost redirect) -- copy the
<code>code=</code> value from the address bar.<br>
3. Paste just the code below and click Connect.
</p>
<p><a id="oauth-authorize-link" href="#" target="_blank" rel="noopener">Open authorization link</a></p>
<label>Authorization code <input id="oauth-code-input" placeholder="paste the code= value here"></label>
<button type="button" id="oauth-connect-btn">Connect</button>
</div>
<div id="isolarcloud-plants" style="margin-top:1rem;"></div>
{% else %}
<p id="isolarcloud-status" class="muted">Checking connection status&hellip;</p>
<button type="button" id="isolarcloud-connect">Test connection</button>
<div id="isolarcloud-plants" style="margin-top:1rem;"></div>
{% endif %}
<div id="isolarcloud-flash"></div>
</div>
<div class="card">
<h3>Your current plan</h3>
<p class="muted">Entered manually so the comparison has a baseline to measure savings against.</p>
<form class="settings-form" id="plan-form">
<label>Retailer name <input name="retailer_name" required></label>
<label>Plan name <input name="plan_name" required></label>
<label>Supply charge ($/day) <input name="supply_charge_dollars_per_day" type="number" step="0.001" required></label>
<label>Plan discount already applied, if any (%) <input name="discount_percent" type="number" step="0.1"></label>
<label>Feed-in tariff type
<select name="feed_in_type" id="feed-in-type">
<option value="flat">Flat rate</option>
<option value="tiered">Tiered by daily export (e.g. "Solar Balance")</option>
</select>
</label>
<div id="flat-fit-fields">
<label>Feed-in tariff (c/kWh) <input name="feed_in_tariff_c_per_kwh" type="number" step="0.01" value="0"></label>
</div>
<div id="tiered-fit-fields" style="display:none;">
<label>Daily threshold (kWh/day) <input name="tiered_daily_threshold_kwh" type="number" step="0.01" placeholder="e.g. 10"></label>
<label>Rate below threshold (c/kWh) <input name="tiered_rate_below_c_per_kwh" type="number" step="0.01"></label>
<label>Rate above threshold (c/kWh) <input name="tiered_rate_above_c_per_kwh" type="number" step="0.01"></label>
</div>
<label>Tariff type
<select name="tariff_type" id="tariff-type">
<option value="flat">Flat rate</option>
<option value="time_of_use">Time of use</option>
</select>
</label>
<div id="flat-fields">
<label>Usage rate (c/kWh) <input name="flat_rate_c_per_kwh" type="number" step="0.01"></label>
</div>
<div id="tou-fields" style="display:none;">
<div id="tou-periods"></div>
<button type="button" id="add-period">+ Add time period</button>
</div>
<button type="submit">Save current plan</button>
</form>
</div>
<script>
async function loadHousehold() {
const cfg = await fetch('/api/settings/household').then(r => r.json());
const form = document.getElementById('household-form');
form.distributor.value = cfg.distributor || 'Energex';
form.postcode.value = cfg.postcode || '';
}
document.getElementById('household-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
await fetch('/api/settings/household', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ distributor: form.distributor.value, postcode: form.postcode.value }),
});
alert('Saved.');
});
const tariffType = document.getElementById('tariff-type');
const flatFields = document.getElementById('flat-fields');
const touFields = document.getElementById('tou-fields');
const touPeriods = document.getElementById('tou-periods');
const feedInType = document.getElementById('feed-in-type');
const flatFitFields = document.getElementById('flat-fit-fields');
const tieredFitFields = document.getElementById('tiered-fit-fields');
feedInType.addEventListener('change', () => {
const isFlat = feedInType.value === 'flat';
flatFitFields.style.display = isFlat ? 'block' : 'none';
tieredFitFields.style.display = isFlat ? 'none' : 'block';
});
const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
function periodRow(values) {
values = values || { label: '', rate_c_per_kwh: '', start_time: '00:00', end_time: '00:00', days: null };
const days = values.days || ALL_DAYS;
const div = document.createElement('div');
div.className = 'tou-period';
const dayCheckboxes = ALL_DAYS.map(d => `
<label class="p-day-label"><input type="checkbox" class="p-day" value="${d}" ${days.includes(d) ? 'checked' : ''}>${d}</label>
`).join('');
div.innerHTML = `
<label>Label <input class="p-label" value="${values.label}" placeholder="e.g. Peak"></label>
<label>Rate (c/kWh) <input class="p-rate" type="number" step="0.01" value="${values.rate_c_per_kwh}"></label>
<label>Start time <input class="p-start" type="time" value="${values.start_time}"></label>
<label>End time <input class="p-end" type="time" value="${values.end_time}"></label>
<div class="p-days-group">Days: ${dayCheckboxes}</div>
<button type="button" class="p-remove">Remove</button>
`;
div.querySelector('.p-remove').addEventListener('click', () => div.remove());
touPeriods.appendChild(div);
}
document.getElementById('add-period').addEventListener('click', () => periodRow());
tariffType.addEventListener('change', () => {
const isFlat = tariffType.value === 'flat';
flatFields.style.display = isFlat ? 'block' : 'none';
touFields.style.display = isFlat ? 'none' : 'block';
if (!isFlat && touPeriods.children.length === 0) {
periodRow({ label: 'Peak', rate_c_per_kwh: '', start_time: '16:00', end_time: '21:00' });
periodRow({ label: 'Off-peak', rate_c_per_kwh: '', start_time: '21:00', end_time: '16:00' });
}
});
async function loadCurrentPlan() {
const plan = await fetch('/api/settings/current-plan').then(r => r.json());
if (!plan) return;
const form = document.getElementById('plan-form');
form.retailer_name.value = plan.retailer;
form.plan_name.value = plan.plan_name;
form.supply_charge_dollars_per_day.value = (plan.supply_charge_c_per_day / 100).toFixed(4);
if (plan.tiered_feed_in) {
feedInType.value = 'tiered';
form.tiered_daily_threshold_kwh.value = plan.tiered_feed_in.daily_threshold_kwh;
form.tiered_rate_below_c_per_kwh.value = plan.tiered_feed_in.rate_below_c_per_kwh;
form.tiered_rate_above_c_per_kwh.value = plan.tiered_feed_in.rate_above_c_per_kwh;
} else {
feedInType.value = 'flat';
form.feed_in_tariff_c_per_kwh.value = plan.feed_in_tariffs[0] ? plan.feed_in_tariffs[0].rate_c_per_kwh : 0;
}
feedInType.dispatchEvent(new Event('change'));
const discount = (plan.discounts || [])[0];
if (discount && discount.percent) form.discount_percent.value = discount.percent;
const isFlat = plan.usage_components.length === 1 && plan.usage_components[0].time_windows.length === 0;
tariffType.value = isFlat ? 'flat' : 'time_of_use';
tariffType.dispatchEvent(new Event('change'));
if (isFlat) {
form.flat_rate_c_per_kwh.value = plan.usage_components[0].rate_c_per_kwh;
} else {
touPeriods.innerHTML = '';
plan.usage_components.forEach(c => {
const w = c.time_windows[0] || { start_time: '00:00', end_time: '00:00', days: null };
periodRow({
label: c.label,
rate_c_per_kwh: c.rate_c_per_kwh,
start_time: w.start_time.slice(0, 5),
end_time: w.end_time.slice(0, 5),
days: w.days,
});
});
}
}
document.getElementById('plan-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const payload = {
retailer_name: form.retailer_name.value,
plan_name: form.plan_name.value,
supply_charge_dollars_per_day: parseFloat(form.supply_charge_dollars_per_day.value),
discount_percent: form.discount_percent.value ? parseFloat(form.discount_percent.value) : null,
tariff_type: tariffType.value,
feed_in_type: feedInType.value,
};
if (tariffType.value === 'flat') {
payload.flat_rate_c_per_kwh = parseFloat(form.flat_rate_c_per_kwh.value);
} else {
payload.tou_periods = Array.from(touPeriods.children).map(div => {
const checkedDays = Array.from(div.querySelectorAll('.p-day:checked')).map(cb => cb.value);
return {
label: div.querySelector('.p-label').value,
rate_c_per_kwh: parseFloat(div.querySelector('.p-rate').value),
start_time: div.querySelector('.p-start').value,
end_time: div.querySelector('.p-end').value,
days: checkedDays.length ? checkedDays : null,
};
});
}
if (feedInType.value === 'flat') {
payload.feed_in_tariff_c_per_kwh = parseFloat(form.feed_in_tariff_c_per_kwh.value || 0);
} else {
payload.tiered_daily_threshold_kwh = parseFloat(form.tiered_daily_threshold_kwh.value);
payload.tiered_rate_below_c_per_kwh = parseFloat(form.tiered_rate_below_c_per_kwh.value);
payload.tiered_rate_above_c_per_kwh = parseFloat(form.tiered_rate_above_c_per_kwh.value);
}
const resp = await fetch('/api/settings/current-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
alert('Current plan saved.');
} else {
const err = await resp.json();
alert('Failed to save: ' + JSON.stringify(err));
}
});
async function checkIsolarcloudStatus() {
const el = document.getElementById('isolarcloud-status');
if (!el) return;
const status = await fetch('/api/isolarcloud/status').then(r => r.json());
el.textContent = status.connected
? 'Connected -- a valid token is cached.'
: 'Not connected yet.';
// Only oauth2 mode has the connect-block/reconnect-toggle pair.
const connectBlock = document.getElementById('oauth-connect-block');
const reconnectToggle = document.getElementById('oauth-reconnect-toggle');
if (!connectBlock || !reconnectToggle) return;
if (status.connected) {
connectBlock.style.display = 'none';
reconnectToggle.style.display = 'inline-block';
} else {
connectBlock.style.display = 'block';
reconnectToggle.style.display = 'none';
}
}
function renderPlantPicker(plants) {
const plantsEl = document.getElementById('isolarcloud-plants');
if (!plants || !plants.length) {
plantsEl.innerHTML = '';
return;
}
const options = plants.map(p => `<option value="${p.ps_id}">${p.ps_name || p.ps_id} (${p.ps_id})</option>`).join('');
plantsEl.innerHTML = `
<label>Plant to poll
<select id="plant-select">${options}</select>
</label>
<button type="button" id="save-plant">Use this plant</button>
`;
fetch('/api/settings/household').then(r => r.json()).then(household => {
if (household.isolarcloud_ps_id) document.getElementById('plant-select').value = household.isolarcloud_ps_id;
});
document.getElementById('save-plant').addEventListener('click', async () => {
const household = await fetch('/api/settings/household').then(r => r.json());
await fetch('/api/settings/household', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...household, isolarcloud_ps_id: document.getElementById('plant-select').value }),
});
alert('Saved.');
});
}
async function connectIsolarcloud() {
const flash = document.getElementById('isolarcloud-flash');
flash.innerHTML = '<p class="muted">Connecting&hellip;</p>';
document.getElementById('isolarcloud-plants').innerHTML = '';
const resp = await fetch('/api/isolarcloud/connect', { method: 'POST' });
const data = await resp.json();
if (!data.ok) {
flash.innerHTML = `<p style="color: var(--bad);">Connection failed: ${data.error}</p>`;
return;
}
flash.innerHTML = '<p style="color: var(--good);">Connected successfully.</p>';
renderPlantPicker(data.plants);
}
const connectButton = document.getElementById('isolarcloud-connect');
if (connectButton) connectButton.addEventListener('click', connectIsolarcloud);
const oauthAuthorizeLink = document.getElementById('oauth-authorize-link');
if (oauthAuthorizeLink) {
fetch('/api/isolarcloud/oauth/authorize-url').then(r => r.json()).then(data => {
oauthAuthorizeLink.href = data.url;
});
document.getElementById('oauth-reconnect-toggle').addEventListener('click', () => {
document.getElementById('oauth-connect-block').style.display = 'block';
});
document.getElementById('oauth-connect-btn').addEventListener('click', async () => {
const flash = document.getElementById('isolarcloud-flash');
let code = document.getElementById('oauth-code-input').value.trim();
// Accept a full pasted URL too, not just the bare code.
const match = code.match(/[?&]code=([^&]+)/);
if (match) code = decodeURIComponent(match[1]);
if (!code) {
flash.innerHTML = '<p style="color: var(--bad);">Paste the authorization code first.</p>';
return;
}
flash.innerHTML = '<p class="muted">Exchanging code&hellip;</p>';
document.getElementById('isolarcloud-plants').innerHTML = '';
const resp = await fetch('/api/isolarcloud/oauth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const data = await resp.json();
if (!data.ok) {
flash.innerHTML = `<p style="color: var(--bad);">Connection failed: ${data.error}</p>`;
return;
}
flash.innerHTML = '<p style="color: var(--good);">Connected successfully.</p>';
renderPlantPicker(data.authorized_plants);
});
}
loadHousehold();
loadCurrentPlan();
checkIsolarcloudStatus();
</script>
{% endblock %}