settings-site: phase 4 client refactor (split app.js into focused modules, shared dropdown helper, strict-CSP-ready)

This commit is contained in:
2026-04-18 18:04:46 +00:00
parent 0ac6debcf9
commit e2443fd94a
8 changed files with 890 additions and 757 deletions

View File

@@ -0,0 +1,277 @@
(function () {
'use strict';
const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d'];
const NOTIFICATION_TAB_KEYS = {
surge: [
'surge_tickets',
'surge_game',
'surge_stale',
'surge_needs_response',
'surge_unclaimed',
'surge_tier3_unclaimed',
'surge_no_staff'
],
patterns: [
'user_tickets',
'user_reopen',
'user_crossgame',
'game_surge',
'game_backlog',
'game_resolution',
'game_spike',
'tag_top',
'tag_escalation',
'untagged_closes',
'tag_game_corr',
'user_esc',
'game_esc_rate',
'rapid_t2_t3',
'staff_no_close',
'staff_overloaded',
'staff_stale',
'staff_transfer_rate',
'staff_esc',
'staff_game_esc',
'game_tag_spike',
'overnight_gap',
'staff_always_esc'
],
unclaimed: ['unclaimed_reminder'],
chat: ['chat_messages', 'chat_time']
};
const NOTIFICATION_ALERT_DESCRIPTIONS = {
surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
surge_needs_response: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.',
surge_unclaimed: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.',
surge_tier3_unclaimed: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.",
surge_no_staff: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.',
user_tickets: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.',
user_reopen: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.',
user_crossgame: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.',
game_surge: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.',
game_backlog: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.',
game_resolution: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.',
game_spike: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.',
tag_top: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.',
tag_escalation: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.',
untagged_closes: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.',
tag_game_corr: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.',
user_esc: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.',
game_esc_rate: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.',
rapid_t2_t3: 'Fires at ticket count milestones (e.g. 3, 5, 10) when tickets have reached Tier 3 this week. Each milestone fires once per week.',
staff_no_close: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.',
staff_overloaded: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.',
staff_stale: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.',
staff_transfer_rate: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.',
staff_esc: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.',
staff_game_esc: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.',
game_tag_spike: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.',
overnight_gap: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.',
staff_always_esc: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.',
unclaimed_reminder: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.',
chat_messages: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.',
chat_time: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.'
};
let notificationThresholdsState = {};
function initNotificationsEditor(config) {
const section = document.getElementById('s-notifications');
if (!section) return;
const hiddenField = section.querySelector('[data-key="NOTIFICATION_THRESHOLDS_JSON"]');
if (!hiddenField) return;
notificationThresholdsState = parseNotificationThresholdsConfig(config);
hiddenField.value = serializeNotificationThresholds(notificationThresholdsState);
section.querySelectorAll('.notif-tab-btn').forEach(btn => {
btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab));
});
Object.entries(NOTIFICATION_TAB_KEYS).forEach(([category, keys]) => {
const select = section.querySelector(`[data-notif-category="${category}"]`);
const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`);
const input = section.querySelector(`[data-notif-input="${category}"]`);
const addBtn = section.querySelector(`[data-notif-add="${category}"]`);
const presetsWrap = section.querySelector(`[data-notif-presets="${category}"]`);
if (!select || !chipsWrap || !input || !addBtn || !presetsWrap) return;
keys.forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = toHumanLabel(key);
select.appendChild(option);
});
if (keys.length) select.value = keys[0];
select.addEventListener('change', () => {
renderThresholdChips(category);
renderAlertDescription(category);
});
addBtn.addEventListener('click', () => addThresholdFromInput(category));
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
addThresholdFromInput(category);
}
});
NOTIFICATION_PRESETS.forEach(preset => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = preset;
btn.addEventListener('click', () => addThresholdValue(category, preset));
presetsWrap.appendChild(btn);
});
renderThresholdChips(category);
renderAlertDescription(category);
});
setNotificationTab('surge');
}
function parseNotificationThresholdsConfig(config) {
const rawJson = config.NOTIFICATION_THRESHOLDS_JSON;
if (rawJson && String(rawJson).trim()) {
try {
const parsed = JSON.parse(rawJson);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
} catch (_) {}
}
if (config.NOTIFICATION_THRESHOLDS && typeof config.NOTIFICATION_THRESHOLDS === 'object' && !Array.isArray(config.NOTIFICATION_THRESHOLDS)) {
return config.NOTIFICATION_THRESHOLDS;
}
return {};
}
function serializeNotificationThresholds(obj) {
const ordered = {};
Object.keys(obj).sort().forEach(key => {
const arr = Array.isArray(obj[key]) ? obj[key].map(v => String(v).trim()).filter(Boolean) : [];
ordered[key] = arr;
});
return JSON.stringify(ordered);
}
function setNotificationTab(category) {
document.querySelectorAll('#s-notifications .notif-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.notifTab === category);
});
document.querySelectorAll('#s-notifications .notif-panel').forEach(panel => {
panel.classList.toggle('hidden', panel.dataset.notifPanel !== category);
});
}
function addThresholdFromInput(category) {
const input = document.querySelector(`#s-notifications [data-notif-input="${category}"]`);
if (!input) return;
const value = input.value.trim().toLowerCase();
if (addThresholdValue(category, value)) input.value = '';
}
function addThresholdValue(category, rawValue) {
const value = String(rawValue || '').trim().toLowerCase();
if (!isValidThresholdValue(value)) {
Util.showToast('Invalid threshold format. Use 15m, 1h, 1d6h, or whole numbers.', 'error');
return false;
}
const alertKey = getSelectedAlertKey(category);
if (!alertKey) return false;
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
if (current.includes(value)) return false;
current.push(value);
notificationThresholdsState[alertKey] = current;
syncNotificationThresholdsField();
renderThresholdChips(category);
return true;
}
function removeThresholdValue(category, valueToRemove) {
const alertKey = getSelectedAlertKey(category);
if (!alertKey) return;
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
notificationThresholdsState[alertKey] = current.filter(v => String(v) !== String(valueToRemove));
syncNotificationThresholdsField();
renderThresholdChips(category);
}
function renderThresholdChips(category) {
const chipsWrap = document.querySelector(`#s-notifications [data-notif-chips="${category}"]`);
if (!chipsWrap) return;
const alertKey = getSelectedAlertKey(category);
const thresholds = alertKey && Array.isArray(notificationThresholdsState[alertKey])
? notificationThresholdsState[alertKey]
: [];
chipsWrap.replaceChildren();
thresholds.forEach(value => {
const chip = document.createElement('span');
chip.className = 'notif-chip';
chip.textContent = value;
const remove = document.createElement('button');
remove.type = 'button';
remove.title = `Remove ${value}`;
remove.textContent = '×';
remove.addEventListener('click', () => removeThresholdValue(category, value));
chip.appendChild(remove);
chipsWrap.appendChild(chip);
});
}
function renderAlertDescription(category) {
const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`);
if (!descriptionEl) return;
const alertKey = getSelectedAlertKey(category);
descriptionEl.textContent = NOTIFICATION_ALERT_DESCRIPTIONS[alertKey] || 'No description available for this alert key yet.';
}
function syncNotificationThresholdsField() {
const hiddenField = document.querySelector('#s-notifications [data-key="NOTIFICATION_THRESHOLDS_JSON"]');
if (!hiddenField) return;
const serialized = serializeNotificationThresholds(notificationThresholdsState);
hiddenField.value = serialized;
Fields.markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized);
hiddenField.classList.toggle('changed', Fields.isChanged('NOTIFICATION_THRESHOLDS_JSON'));
}
function getSelectedAlertKey(category) {
const select = document.querySelector(`#s-notifications [data-notif-category="${category}"]`);
return select ? select.value : '';
}
function isValidThresholdValue(value) {
if (!value) return false;
if (/^\d+$/.test(value)) return true;
return /^(\d+[mhd])+$/.test(value);
}
function toHumanLabel(key) {
return String(key)
.split('_')
.map(part => part.toUpperCase() === 'T2' || part.toUpperCase() === 'T3'
? part.toUpperCase()
: part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
window.Notifications = {
initNotificationsEditor,
isValidThresholdValue,
toHumanLabel,
NOTIFICATION_PRESETS,
NOTIFICATION_TAB_KEYS,
NOTIFICATION_ALERT_DESCRIPTIONS
};
})();