Merge pull request #76 from zvx-echo6/v0_9_15_live_quota

v0.9.15: live JS quota recompute on multi-bbox editor
This commit is contained in:
malice 2026-05-26 22:30:07 -06:00 committed by GitHub
commit eb97ffb24c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 91 additions and 4 deletions

View file

@ -333,6 +333,8 @@ class TomTomIncidentsAdapter(SourceAdapter):
return {
"calls_per_month": calls_per_month,
"cap": cap,
"seconds_per_month": _SECONDS_PER_MONTH,
"default_cadence_s": cls.default_cadence_s,
"percent": percent,
"warn": percent >= 80.0,
"blocked": percent >= 100.0,

View file

@ -66,10 +66,13 @@
</div>
{% if quota %}
<div class="quota-panel flash {% if quota.blocked %}flash-error{% elif quota.warn %}flash-warn{% endif %}">
<strong>API quota:</strong> {{ quota.detail }}
{% if quota.blocked %}<br>⛔ Over free-tier cap — reduce calls before saving.
{% elif quota.warn %}<br>⚠️ Approaching free-tier cap.{% endif %}
<div class="quota-panel flash {% if quota.blocked %}flash-error{% elif quota.warn %}flash-warn{% endif %}"
data-field="{{ field.name }}"
data-quota-cap="{{ quota.cap }}"
data-quota-spm="{{ quota.seconds_per_month }}"
data-quota-default="{{ quota.default_cadence_s }}">
<strong>API quota:</strong> <span class="quota-detail">{{ quota.detail }}</span>
<span class="quota-msg">{% if quota.blocked %}<br>⛔ Over free-tier cap — reduce calls before saving.{% elif quota.warn %}<br>⚠️ Approaching free-tier cap.{% endif %}</span>
</div>
{% endif %}
@ -103,3 +106,64 @@
});
})();
</script>
<script>
// v0.9.15: live client-side mirror of TomTomIncidentsAdapter.quota_estimate --
// recompute the est. monthly call count as the operator edits a cadence input
// or adds/removes a bbox row, without waiting for Save. Vanilla, self-contained,
// and a no-op when no quota panel is present (non-TomTom model_lists).
(function() {
function init() {
var panel = document.querySelector('.quota-panel[data-field]');
if (!panel) return;
var container = document.querySelector(
'.model-list[data-field="' + panel.dataset.field + '"]');
if (!container) return;
var body = container.querySelector('.model-list-body');
if (!body) return;
var CAP = parseFloat(panel.dataset.quotaCap);
var SPM = parseFloat(panel.dataset.quotaSpm);
var DEF = parseFloat(panel.dataset.quotaDefault);
var detailEl = panel.querySelector('.quota-detail');
var msgEl = panel.querySelector('.quota-msg');
// Server uses the adapter-level cadence as a floor:
// effective = max(adapter_cadence, bbox_cadence || default).
function adapterCadence() {
var el = document.getElementById('cadence_s');
var v = el ? parseFloat(el.value) : NaN;
return isNaN(v) ? 0 : v;
}
function recompute() {
var rows = body.querySelectorAll('.model-list-row');
var floor = adapterCadence(), calls = 0;
rows.forEach(function(row) {
var el = row.querySelector('[data-sub="cadence_s"]');
var bc = el ? parseFloat(el.value) : NaN;
var eff = Math.max(floor, isNaN(bc) ? DEF : bc);
if (eff > 0) calls += SPM / eff;
});
var cpm = Math.round(calls);
var pct = CAP ? (cpm / CAP * 100) : 0;
var warn = pct >= 80, blocked = pct >= 100;
detailEl.textContent = cpm.toLocaleString() + ' est. calls/month across '
+ rows.length + ' bbox(es) vs ' + CAP.toLocaleString()
+ '/month free tier (' + Math.round(pct) + '%)';
msgEl.innerHTML = blocked
? '<br>⛔ Over free-tier cap — reduce calls before saving.'
: (warn ? '<br>⚠️ Approaching free-tier cap.' : '');
panel.classList.toggle('flash-error', blocked);
panel.classList.toggle('flash-warn', warn && !blocked);
}
body.addEventListener('input', recompute);
var core = document.getElementById('cadence_s');
if (core) core.addEventListener('input', recompute);
new MutationObserver(recompute).observe(body, { childList: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>

View file

@ -56,6 +56,27 @@ class TestModelListRender:
assert "model-list" in out and 'name="bboxes-0-name"' in out
assert "free tier" in out # quota panel rendered
def test_quota_panel_exposes_client_recompute_constants(self):
# v0.9.15: data-quota-* attrs let the client JS mirror the server
# formula with no hardcoded magic numbers.
fields = describe_fields(TomTomIncidentsAdapter.settings_schema, _SETTINGS)
quota = TomTomIncidentsAdapter.quota_estimate(TomTomIncidentsSettings(**_SETTINGS), 1800)
out = _render("adapters_edit.html", _ctx(_SETTINGS, fields, quota))
assert 'data-quota-cap="2500"' in out
assert 'data-quota-spm="2592000"' in out
assert 'data-quota-default="1800"' in out
def test_quota_panel_wraps_detail_and_msg_for_live_update(self):
# v0.9.15: detail/msg split into spans so the IIFE can rewrite the text
# and warn/block state live. JS exec needs a browser, so this is
# structural only -- live behaviour (cadence edit + Add/Delete row)
# was eyeballed manually against /adapters/tomtom_incidents.
fields = describe_fields(TomTomIncidentsAdapter.settings_schema, _SETTINGS)
quota = TomTomIncidentsAdapter.quota_estimate(TomTomIncidentsSettings(**_SETTINGS), 1800)
out = _render("adapters_edit.html", _ctx(_SETTINGS, fields, quota))
assert '<span class="quota-detail">' in out
assert '<span class="quota-msg">' in out
def test_nws_region_intact_no_model_list(self):
from central.adapters.nws import NWSSettings
s = {"contact_email": "a@b.com",