Add settings dialog and first-visit setup prompt

A gear button and a setup prompt edit the saved settings, with name
suggestions, and apply them without a reload. Panels and status columns
can be hidden; the closed watcher and ding keep running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Skylar Grant 2026-10-15 21:04:22 -04:00
parent 9479b122fa
commit 53c519ed62

View File

@ -519,6 +519,122 @@ body > footer p + p { margin-top: 4px; }
.verify td, .verify th { text-align: left; } .verify td, .verify th { text-align: left; }
.verify td.subject-cell { white-space: normal; min-width: 260px; } .verify td.subject-cell { white-space: normal; min-width: 260px; }
/* ---- settings ---- */
dialog {
width: calc(100% - 32px);
max-width: 640px;
max-height: calc(100vh - 48px);
padding: 0;
background: var(--card);
color: var(--ink);
border: 1px solid var(--rule);
}
dialog::backdrop { background: hsla(210, 18%, 4%, 0.7); }
.settings-head { padding: 20px 24px 4px; }
.settings-head h2 { margin: 0; font-size: 17px; font-weight: 600; }
.settings-head p { margin: 6px 0 0; color: var(--muted); font-size: 14px; }
.settings-body { padding: 4px 24px; }
fieldset {
margin: 16px 0 0;
padding: 0;
border: none;
}
legend, .field > label {
display: block;
margin-bottom: 6px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.field + .field { margin-top: 14px; }
.hint { margin-top: 4px; font-size: 12px; color: var(--faint); }
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px;
background: var(--ground);
border: 1px solid var(--rule);
}
.chips:focus-within { border-color: var(--signal); }
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 9px;
background: var(--card-raised);
border: 1px solid var(--rule);
font-size: 13px;
}
.chip button {
padding: 0 5px;
background: none;
color: var(--muted);
font-size: 15px;
line-height: 1;
}
.chip button:hover { background: none; color: var(--alert); }
.chips input {
flex: 1;
min-width: 160px;
padding: 4px;
background: none;
border: none;
color: var(--ink);
font: inherit;
font-size: 13px;
outline: none;
}
.row { display: flex; flex-wrap: wrap; gap: 14px 24px; }
.row .field + .field { margin-top: 0; }
input[type="number"], select {
padding: 7px 9px;
background: var(--ground);
border: 1px solid var(--rule);
color: var(--ink);
font: inherit;
font-size: 14px;
}
input[type="number"] { width: 90px; }
input[type="number"]:focus, select:focus { outline: none; border-color: var(--signal); }
.checks { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 14px; }
.checks label { display: flex; align-items: center; gap: 7px; cursor: pointer; }
.checks input { accent-color: var(--signal); }
.settings-error {
margin: 16px 24px 0;
padding: 10px 14px;
border-left: 3px solid var(--alert);
background: var(--ground);
font-size: 14px;
}
.settings-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 20px;
padding: 16px 24px;
border-top: 1px solid var(--rule);
}
.settings-foot .spacer { flex: 1; }
button.secondary { background: var(--card-raised); color: var(--ink); border: 1px solid var(--rule); }
button.secondary:hover { background: var(--rule); }
button:disabled { opacity: 0.5; cursor: default; }
.board.solo { grid-template-columns: minmax(0, 1fr); }
@media (max-width: 1040px) { @media (max-width: 1040px) {
.board { grid-template-columns: minmax(0, 1fr); } .board { grid-template-columns: minmax(0, 1fr); }
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@ -552,6 +668,7 @@ body > footer p + p { margin-top: 4px; }
<span class="track"></span>Sound on close <span class="track"></span>Sound on close
</button> </button>
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button> <button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
<button class="quiet" id="settingsBtn" onclick="openSettings('edit')" hidden>⚙ Settings</button>
<span class="user" id="user" hidden></span> <span class="user" id="user" hidden></span>
<button class="quiet" id="signOutBtn" onclick="signOut(this)" hidden>Sign out</button> <button class="quiet" id="signOutBtn" onclick="signOut(this)" hidden>Sign out</button>
</div> </div>
@ -562,6 +679,8 @@ body > footer p + p { margin-top: 4px; }
<div id="main"></div> <div id="main"></div>
</div> </div>
<dialog id="settings" aria-labelledby="settingsTitle"></dialog>
<script> <script>
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@ -602,6 +721,8 @@ let flashTechs = new Set();
// the browser builds the closed feed now; the server only answers each check with the user's token // the browser builds the closed feed now; the server only answers each check with the user's token
const feed = new Map(); const feed = new Map();
let feedReady = false; let feedReady = false;
// bumped when settings change, so a check that started under the old settings is dropped
let generation = 0;
let timers = []; let timers = [];
let signInProblem = null; let signInProblem = null;
@ -675,20 +796,29 @@ async function start() {
checkClosed(); checkClosed();
checkWorkload(); checkWorkload();
loadWeather(); loadWeather();
timers.push( startTimers();
// first visit: offer the setup prompt; the dashboard loads with the defaults behind it
if (!data.settingsSaved && data.canSaveSettings) openSettings('setup');
}
function startTimers() {
timers.forEach(clearInterval);
timers = [
setInterval(checkClosed, data.checkMinutes * 60 * 1000), setInterval(checkClosed, data.checkMinutes * 60 * 1000),
setInterval(checkWorkload, data.workloadMinutes * 60 * 1000), setInterval(checkWorkload, data.settings.workloadMinutes * 60 * 1000),
setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner
); ];
} }
async function checkClosed() { async function checkClosed() {
// the whole feed on first load, then a short window that overlaps the last check // the whole feed on first load, then a short window that overlaps the last check
const minutes = feedReady ? data.windowMinutes : data.feedHours * 60; const minutes = feedReady ? data.windowMinutes : data.settings.feedHours * 60;
const gen = generation;
try { try {
const result = await api('/api/closed?minutes=' + minutes); const result = await api('/api/closed?minutes=' + minutes);
if (gen !== generation) return;
for (const t of result.tickets) feed.set(String(t.id), t); for (const t of result.tickets) feed.set(String(t.id), t);
const cutoff = Date.now() - data.feedHours * 60 * 60 * 1000; const cutoff = Date.now() - data.settings.feedHours * 60 * 60 * 1000;
for (const [id, t] of feed) { for (const [id, t] of feed) {
if (t.closedAt < cutoff) feed.delete(id); if (t.closedAt < cutoff) feed.delete(id);
} }
@ -705,8 +835,10 @@ async function checkClosed() {
} }
async function checkWorkload() { async function checkWorkload() {
const gen = generation;
try { try {
const result = await api('/api/workload'); const result = await api('/api/workload');
if (gen !== generation) return;
data.workload = result.workload; data.workload = result.workload;
data.unassigned = result.unassigned; data.unassigned = result.unassigned;
} catch (err) { } catch (err) {
@ -726,6 +858,7 @@ function render() {
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth; $('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
const live = data.auth === 'connected'; const live = data.auth === 'connected';
$('checkBtn').hidden = !live; $('checkBtn').hidden = !live;
$('settingsBtn').hidden = !live;
$('signOutBtn').hidden = !live; $('signOutBtn').hidden = !live;
$('user').hidden = !live; $('user').hidden = !live;
const user = data.user || {}; const user = data.user || {};
@ -733,10 +866,10 @@ function render() {
$('user').title = user.email || ''; $('user').title = user.email || '';
const wl = data.workload || {}; const wl = data.workload || {};
const s = data.settings || { sites: [], groups: [], technicians: [] }; const s = data.settings || { sites: [], groups: [], technicians: [], hiddenPanels: [] };
$('meta').innerHTML = !live ? '' : [ $('meta').innerHTML = !live ? '' : [
'Closed tickets checked every ' + data.checkMinutes + ' min', 'Closed tickets checked every ' + data.checkMinutes + ' min',
'Status counts every ' + data.workloadMinutes + ' min', 'Status counts every ' + s.workloadMinutes + ' min',
s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians', s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians',
s.sites.length ? 'Sites: ' + s.sites.join(', ') : '', s.sites.length ? 'Sites: ' + s.sites.join(', ') : '',
s.groups.length ? 'Groups: ' + s.groups.join(', ') : '', s.groups.length ? 'Groups: ' + s.groups.join(', ') : '',
@ -766,13 +899,13 @@ function render() {
} }
if (data.auth === 'connected') { if (data.auth === 'connected') {
html += renderStats(wl); const shown = panel => !s.hiddenPanels.includes(panel);
html += `<div class="board"> if (shown('stats')) html += renderStats(wl);
// the watcher and its ding keep running with the feed hidden; only the list goes
const side = (shown('weather') ? renderWeather() : '') + (shown('feed') ? renderFeed() : '');
html += `<div class="board${side ? '' : ' solo'}">
${renderWorkload(wl)} ${renderWorkload(wl)}
<div class="side"> ${side ? `<div class="side">${side}</div>` : ''}
${renderWeather()}
${renderFeed()}
</div>
</div>`; </div>`;
html += renderUnassignedList(data.unassigned || {}); html += renderUnassignedList(data.unassigned || {});
} }
@ -816,7 +949,9 @@ function renderStats(wl) {
} }
function renderWorkload(wl) { function renderWorkload(wl) {
const statuses = wl.statuses || []; // hidden columns only leave the table; Total open still counts them
const hidden = new Set(data.settings.hiddenStatuses.map(n => n.toLowerCase()));
const statuses = (wl.statuses || []).filter(st => !hidden.has(st.name.toLowerCase()));
const techs = wl.technicians || []; const techs = wl.technicians || [];
const note = wl.updatedAt const note = wl.updatedAt
@ -945,7 +1080,7 @@ function renderFeed() {
: '<div class="empty"><p>Nothing closed yet.</p></div>'; : '<div class="empty"><p>Nothing closed yet.</p></div>';
return `<section class="section"> return `<section class="section">
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.feedHours}h</span></div> <div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.settings.feedHours}h</span></div>
${list} ${list}
</section>`; </section>`;
} }
@ -998,6 +1133,244 @@ async function loadWeather() {
} catch (err) {} } catch (err) {}
} }
// ---- settings: saved on the server per Zoho user, so they follow people between browsers ----
const LIST_FIELDS = { sites: 'Sites', groups: 'Groups', technicians: 'Technicians' };
const PANEL_LABELS = { stats: 'Stat tiles', weather: 'Weather', feed: 'Recently closed feed' };
let names = null; // suggestions from /api/names, fetched the first time settings open
let draft = null;
let settingsMode = 'edit'; // 'setup' on first visit
let settingsError = null;
const lower = list => list.map(n => n.toLowerCase());
function openSettings(mode) {
settingsMode = mode;
settingsError = null;
draft = JSON.parse(JSON.stringify(data.settings));
renderSettings();
if (!$('settings').open) $('settings').showModal();
loadNames();
}
async function loadNames() {
if (names) return;
try {
names = await api('/api/names');
if ($('settings').open) renderSettings();
} catch (err) {}
}
function renderSettings() {
const d = draft;
const lim = data.settingsLimits;
const setup = settingsMode === 'setup';
// keep the cursor in the list being typed into across re-renders
const focused = document.activeElement && document.activeElement.dataset.add;
const chipList = field => {
const taken = new Set(lower(d[field]));
const options = ((names && names[field]) || []).filter(n => !taken.has(n.toLowerCase()));
return `<div class="field">
<label for="add-${field}">${LIST_FIELDS[field]}</label>
<div class="chips">
${d[field].map((n, i) => `<span class="chip">${esc(n)}<button type="button" data-remove="${field}" data-index="${i}" aria-label="Remove ${esc(n)}">×</button></span>`).join('')}
<input id="add-${field}" data-add="${field}" list="names-${field}" autocomplete="off"
placeholder="${d[field].length ? 'Add another…' : 'All ' + field + '. Type to add one'}">
</div>
<datalist id="names-${field}">${options.map(n => `<option value="${esc(n)}">`).join('')}</datalist>
</div>`;
};
// columns from the last count, plus any hidden ones that didn't show up this time
const statusNames = ((data.workload && data.workload.statuses) || []).map(st => st.name);
for (const n of d.hiddenStatuses) if (!lower(statusNames).includes(n.toLowerCase())) statusNames.push(n);
const hiddenStatuses = new Set(lower(d.hiddenStatuses));
const body = !data.canSaveSettings
? `<p class="settings-error">Zoho didn't share your email at sign-in, so settings can't be saved. Sign out and in again.</p>`
: `<div class="settings-body">
<fieldset>
<legend>What to watch</legend>
${Object.keys(LIST_FIELDS).map(chipList).join('')}
<div class="hint">Leave a list empty to include everyone. Suggestions come from recent tickets; any name can be typed, but it has to match ServiceDesk Plus exactly.</div>
</fieldset>
<fieldset>
<legend>Timing</legend>
<div class="row">
<div class="field">
<label for="set-workload">Recount status table every (min)</label>
<input type="number" id="set-workload" data-num="workloadMinutes" min="${lim.workloadMinutes.min}" max="${lim.workloadMinutes.max}" value="${d.workloadMinutes}">
</div>
<div class="field">
<label for="set-feed">Keep closed tickets for (hours)</label>
<input type="number" id="set-feed" data-num="feedHours" min="${lim.feedHours.min}" max="${lim.feedHours.max}" value="${d.feedHours}">
</div>
</div>
</fieldset>
${data.places.length ? `<fieldset>
<legend>Weather</legend>
<div class="field">
<label for="set-zip">Location</label>
<select id="set-zip" data-zip>
${d.weatherZip ? '' : '<option value="" selected>Choose a location…</option>'}
${data.places.map(p => `<option value="${esc(p.zip)}"${p.zip === d.weatherZip ? ' selected' : ''}>${esc(p.label)}</option>`).join('')}
</select>
</div>
</fieldset>` : ''}
<fieldset>
<legend>Show on the dashboard</legend>
<div class="checks">
${lim.panels.map(p => `<label><input type="checkbox" data-panel="${p}"${d.hiddenPanels.includes(p) ? '' : ' checked'}>${PANEL_LABELS[p] || p}</label>`).join('')}
</div>
<div class="hint">The ding for newly closed tickets plays even with the feed hidden.</div>
</fieldset>
<fieldset>
<legend>Status columns</legend>
${statusNames.length
? `<div class="checks">${statusNames.map(n => `<label><input type="checkbox" data-status="${esc(n)}"${hiddenStatuses.has(n.toLowerCase()) ? '' : ' checked'}>${esc(n)}</label>`).join('')}</div>
<div class="hint">Hidden columns still count toward Total open.</div>`
: '<div class="hint">The columns appear here after the first status count.</div>'}
</fieldset>
</div>`;
const foot = setup
? `<span class="spacer"></span>
<button type="button" class="secondary" data-action="defaults">Use the defaults</button>
<button type="button" data-action="save">Save</button>`
: `<button type="button" class="quiet" data-action="reset"${data.canSaveSettings ? '' : ' disabled'}>Reset to defaults</button>
<span class="spacer"></span>
<button type="button" class="secondary" data-action="cancel">Cancel</button>
<button type="button" data-action="save"${data.canSaveSettings ? '' : ' disabled'}>Save</button>`;
$('settings').innerHTML = `<div class="settings-head">
<h2 id="settingsTitle">${setup ? 'Set up your dashboard' : 'Settings'}</h2>
<p>${setup
? 'Pick what this dashboard watches. It starts with the help desk defaults, and you can change it any time with ⚙ Settings.'
: 'Saved to your Zoho account, so they follow you to any browser you sign in on.'}</p>
</div>
${settingsError ? `<div class="settings-error">${esc(settingsError)}</div>` : ''}
${body}
<div class="settings-foot">${foot}</div>`;
if (focused) $('add-' + focused).focus();
}
function addName(field, input) {
const name = input.value.replace(/\s+/g, ' ').trim().replace(/,$/, '');
input.value = '';
if (!name || lower(draft[field]).includes(name.toLowerCase())) return;
// take the suggestion's spelling when only the case differs
const match = ((names && names[field]) || []).find(n => n.toLowerCase() === name.toLowerCase());
draft[field].push(match || name);
renderSettings();
}
$('settings').addEventListener('keydown', e => {
const field = e.target.dataset.add;
if (!field) return;
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addName(field, e.target);
} else if (e.key === 'Backspace' && !e.target.value && draft[field].length) {
draft[field].pop();
renderSettings();
}
});
// picking a suggestion from the list fills the input in one go; add it straight away
$('settings').addEventListener('input', e => {
const field = e.target.dataset.add;
if (!field || (e.inputType && e.inputType !== 'insertReplacementText')) return;
if (((names && names[field]) || []).includes(e.target.value)) addName(field, e.target);
});
$('settings').addEventListener('change', e => {
const t = e.target;
if (t.dataset.num) draft[t.dataset.num] = Number(t.value);
if (t.dataset.zip !== undefined) draft.weatherZip = t.value;
if (t.dataset.panel) {
draft.hiddenPanels = draft.hiddenPanels.filter(p => p !== t.dataset.panel);
if (!t.checked) draft.hiddenPanels.push(t.dataset.panel);
}
if (t.dataset.status) {
draft.hiddenStatuses = draft.hiddenStatuses.filter(n => n.toLowerCase() !== t.dataset.status.toLowerCase());
if (!t.checked) draft.hiddenStatuses.push(t.dataset.status);
}
});
$('settings').addEventListener('click', e => {
const remove = e.target.closest('[data-remove]');
if (remove) {
draft[remove.dataset.remove].splice(Number(remove.dataset.index), 1);
renderSettings();
return;
}
const action = e.target.closest('[data-action]');
if (!action) return;
if (action.dataset.action === 'cancel') $('settings').close();
if (action.dataset.action === 'save') saveSettings(action);
if (action.dataset.action === 'defaults') saveSettings(action, {});
if (action.dataset.action === 'reset') saveSettings(action, null);
});
// body null resets to the defaults; {} keeps the defaults but stops the setup prompt
async function saveSettings(btn, body) {
if (body === undefined) {
// a name typed but not yet added with Enter still counts
for (const field of Object.keys(LIST_FIELDS)) {
const input = $('add-' + field);
if (input && input.value.trim()) addName(field, input);
}
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels } = draft;
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels };
if (data.places.length && weatherZip) body.weatherZip = weatherZip;
}
btn.disabled = true;
try {
const reply = await api('/api/settings', body === null
? { method: 'DELETE' }
: { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
$('settings').close();
applySettings(reply);
} catch (err) {
if (data.auth !== 'connected') return $('settings').close();
settingsError = err.message;
renderSettings();
}
}
function applySettings(reply) {
const before = data.settings;
Object.assign(data, reply);
const after = data.settings;
const changed = (...fields) => fields.some(f => JSON.stringify(before[f]) !== JSON.stringify(after[f]));
if (changed('sites', 'groups', 'technicians', 'feedHours')) generation++;
if (changed('sites', 'groups', 'technicians', 'feedHours')) {
// a different region or feed length: start the feed over, quietly, like a fresh page load
feed.clear();
data.tickets = [];
seen = new Set();
feedReady = false;
firstLoad = true;
checkClosed();
}
if (changed('sites', 'groups', 'technicians')) {
data.workload = {};
data.unassigned = {};
checkWorkload();
}
if (changed('workloadMinutes')) startTimers();
if (changed('weatherZip')) {
weather = null;
loadWeather();
}
render();
}
start(); start();
setInterval(render, 20000); // re-age the rails between checks setInterval(render, 20000); // re-age the rails between checks
</script> </script>