phase 9 notification toggles (per-alert, per-category, master; default-disabled)
This commit is contained in:
@@ -771,6 +771,41 @@ body::before {
|
||||
#s-notifications .notif-trigger[open] summary::before { content: '−'; }
|
||||
#s-notifications .notif-trigger[open] summary { color: var(--primary); }
|
||||
|
||||
/* Phase 9 — notification enable toggles */
|
||||
#s-notifications .notif-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#s-notifications .notif-toggle-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
#s-notifications .notif-toggle-label {
|
||||
font-family: var(--font-title);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
#s-notifications .notif-per-alert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.notif-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Logging hint link */
|
||||
.logging-hint { color: var(--text-muted); font-size: 13px; }
|
||||
.logging-hint a {
|
||||
|
||||
@@ -173,10 +173,33 @@
|
||||
</div>
|
||||
|
||||
<div class="notif-panel" data-notif-panel="surge">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="surge">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="surge"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="surge"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
@@ -208,10 +231,33 @@
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="patterns">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="patterns">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="patterns"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
@@ -234,10 +280,33 @@
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="unclaimed">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="unclaimed">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="unclaimed"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
@@ -254,10 +323,33 @@
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="chat">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="chat">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="chat"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="chat"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user