phase 9 notification toggles (per-alert, per-category, master; default-disabled)

This commit is contained in:
2026-04-18 23:51:59 +00:00
parent 39a5482516
commit 8a45b59b28
12 changed files with 520 additions and 33 deletions

View File

@@ -80,6 +80,11 @@
let notificationThresholdsState = {};
// Phase 9: notification enable state. Fetched from /api/notifications/state
// on init. `master` is the global kill switch; `perKey` is a flat map of
// alertKey → boolean. Both default to off so a fresh deploy is silent.
let enableState = { master: false, perKey: {} };
// Active sources. Start as fallback; replaced/merged when the bot-side
// registry (GET /api/notifications/alerts) returns successfully. On 404 or
// network failure the fallbacks remain authoritative.
@@ -102,6 +107,35 @@
}
}
async function fetchEnableState() {
try {
const res = await fetch('/api/notifications/state', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json();
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
if (typeof data.master !== 'boolean') return null;
if (!data.perKey || typeof data.perKey !== 'object') return null;
return { master: data.master, perKey: { ...data.perKey } };
} catch (_) {
return null;
}
}
async function postToggle(body) {
const res = await fetch('/api/notifications/toggle', {
method: 'POST',
credentials: 'same-origin',
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`toggle ${res.status}`);
const data = await res.json();
if (!data || !data.state || typeof data.state.master !== 'boolean' || !data.state.perKey) {
throw new Error('bad toggle response');
}
return { master: data.state.master, perKey: { ...data.state.perKey } };
}
// Merge bot registry with fallback, preserving fallback order for existing
// keys (so rapid_t2_t3 and chat keys stay where the UI expects them).
// Registry-only keys get appended to their category; registry descriptions
@@ -185,6 +219,7 @@
select.addEventListener('change', () => {
renderThresholdChips(category);
renderAlertDescription(category);
renderPerAlertToggle(category);
});
addBtn.addEventListener('click', () => addThresholdFromInput(category));
input.addEventListener('keydown', (evt) => {
@@ -208,6 +243,8 @@
setNotificationTab('surge');
wireEnableToggles(section);
// Background: pull canonical registry from the bot, merge with fallback,
// append any registry-only keys and refresh descriptions. Fallback stays
// in use if the endpoint 404s (settings-site deployed ahead of bot) or
@@ -215,7 +252,128 @@
fetchAlertRegistry().then(registry => {
if (!registry) return;
applyMergedRegistry(section, registry);
renderAllEnableUI(section);
}).catch(() => {});
// Phase 9: pull enable state and paint the toggles. Runs in parallel with
// the registry fetch above; renderAll is idempotent so ordering is OK.
fetchEnableState().then(state => {
if (!state) return;
enableState = state;
window.Notifications.state = state;
renderAllEnableUI(section);
}).catch(() => {});
}
function keysForCategory(category) {
return activeTabKeys[category] || [];
}
function renderMasterCheckboxes(section) {
section.querySelectorAll('[data-notif-master]').forEach(cb => {
cb.checked = enableState.master === true;
});
}
function renderCategoryCheckbox(section, category) {
const cb = section.querySelector(`[data-notif-category-toggle="${category}"]`);
if (!cb) return;
const keys = keysForCategory(category);
const allOn = keys.length > 0 && keys.every(k => enableState.perKey[k] === true);
cb.checked = enableState.master === true && allOn;
}
function renderPerAlertToggle(category) {
const section = document.getElementById('s-notifications');
if (!section) return;
const panel = section.querySelector(`[data-notif-panel="${category}"]`);
if (!panel) return;
const alertCb = panel.querySelector('[data-notif-alert]');
const label = panel.querySelector('[data-notif-alert-label]');
const alertKey = getSelectedAlertKey(category);
const on = alertKey ? enableState.perKey[alertKey] === true : false;
if (alertCb) alertCb.checked = on;
if (label) label.textContent = on ? 'Alert enabled' : 'Alert disabled';
// Disable the chip/input editor when master is off or this alert is off.
const editor = panel.querySelector('.notif-editor');
if (editor) {
const disabled = !enableState.master || !on;
editor.classList.toggle('notif-disabled', disabled);
}
}
function renderAllEnableUI(section) {
renderMasterCheckboxes(section);
Object.keys(activeTabKeys).forEach(cat => renderCategoryCheckbox(section, cat));
Object.keys(activeTabKeys).forEach(cat => renderPerAlertToggle(cat));
}
function wireEnableToggles(section) {
// Master — multiple DOM copies; they share state via enableState and
// re-render after each mutation, so clicking any one updates all.
section.querySelectorAll('[data-notif-master]').forEach(cb => {
cb.addEventListener('change', async () => {
const prev = enableState.master;
const next = cb.checked;
try {
const newState = await postToggle({ master: true, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
cb.checked = prev;
renderAllEnableUI(section);
Util.showToast('Failed to update master toggle.', 'error');
}
});
});
// Per-category "All in category" toggles.
section.querySelectorAll('[data-notif-category-toggle]').forEach(cb => {
const category = cb.dataset.notifCategoryToggle;
cb.addEventListener('change', async () => {
const next = cb.checked;
const prev = !next; // pre-toggle state, for revert on failure
try {
const newState = await postToggle({ category, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
cb.checked = prev;
renderAllEnableUI(section);
Util.showToast(`Failed to update ${category} category toggle.`, 'error');
}
});
});
// Per-alert toggles — one per category panel, scoped to the currently
// selected alertKey in that panel's dropdown.
section.querySelectorAll('.notif-panel').forEach(panel => {
const category = panel.dataset.notifPanel;
const alertCb = panel.querySelector('[data-notif-alert]');
if (!alertCb) return;
alertCb.addEventListener('change', async () => {
const alertKey = getSelectedAlertKey(category);
if (!alertKey) {
alertCb.checked = false;
return;
}
const prev = enableState.perKey[alertKey] === true;
const next = alertCb.checked;
try {
const newState = await postToggle({ key: alertKey, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
alertCb.checked = prev;
renderAllEnableUI(section);
Util.showToast(`Failed to update ${alertKey} toggle.`, 'error');
}
});
});
}
function parseNotificationThresholdsConfig(config) {
@@ -351,10 +509,12 @@
isValidThresholdValue,
toHumanLabel,
fetchAlertRegistry,
fetchEnableState,
NOTIFICATION_PRESETS,
FALLBACK_TAB_KEYS,
FALLBACK_ALERT_DESCRIPTIONS,
registry: null,
state: enableState,
get tabKeys() { return activeTabKeys; },
get alertDescriptions() { return activeAlertDescriptions; }
};