sdp-request-watcher/src/public/index.html
Skylar Grant d5a1080185
All checks were successful
Build and push image / build (push) Successful in 15s
Remove ticket subjects from SDP queries and the page
Subjects can name students or describe their situation (FERPA). The
closed feed and unassigned queries no longer request subject, the
returned objects never include it, and feed cards show the ticket
number instead. SECURITY.md, NOTES.md and the README are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:24:52 -04:00

1376 lines
42 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Help desk dashboard</title>
<meta name="author" content="Skylar Grant, Isaac Godin (design), Claude by Anthropic">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ground: hsl(210, 18%, 8%);
--card: hsl(210, 16%, 13%);
--card-raised: hsl(210, 15%, 16%);
--ink: hsl(30, 30%, 96%);
--muted: hsl(215, 12%, 68%);
--faint: hsl(215, 10%, 42%);
--rule: hsl(210, 12%, 24%);
--signal: hsl(28, 95%, 58%);
--signal-soft: hsl(28, 70%, 22%);
--alert: hsl(14, 90%, 58%);
--font-ui: 'Outfit', system-ui, -apple-system, 'Segoe UI', sans-serif;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: var(--font-ui);
background: var(--ground);
color: var(--ink);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap {
width: 100%;
max-width: 1320px;
flex: 1;
margin: 0 auto;
padding: 28px 24px 48px;
}
/* ---- header ---- */
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
padding-bottom: 14px;
border-bottom: 2px solid var(--ink);
}
h1 {
margin: 0;
font-family: var(--mono);
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
}
.status {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
flex: none;
}
.status[data-auth="connected"] .dot {
background: var(--signal);
animation: pulse 2.4s ease-in-out infinite;
}
.status[data-auth="error"] .dot { background: var(--alert); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
.actions {
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin: 12px 0 0;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
/* ---- panels ---- */
.panel {
margin-top: 24px;
padding: 24px;
background: var(--card);
border: 1px solid var(--rule);
}
.panel h2 {
margin: 0 0 6px;
font-size: 16px;
font-weight: 600;
}
.panel p {
margin: 0 0 16px;
color: var(--muted);
max-width: 80ch;
}
.user {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
button, .button {
display: inline-block;
padding: 9px 18px;
border: none;
background: var(--ink);
color: var(--ground);
font-family: var(--font-ui);
font-size: 14px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
}
button:hover, .button:hover { background: var(--signal); }
button:focus-visible, .button:focus-visible,
a:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }
button.quiet {
background: none;
color: var(--muted);
padding: 4px 0;
font-family: var(--mono);
font-size: 12px;
text-decoration: underline;
text-underline-offset: 3px;
}
button.quiet:hover { background: none; color: var(--ink); }
button.quiet:disabled { opacity: 0.5; cursor: default; }
.toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
background: none;
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
}
.toggle:hover { background: none; color: var(--ink); }
.toggle .track {
position: relative;
width: 26px;
height: 14px;
border-radius: 7px;
background: var(--rule);
transition: background 0.15s;
}
.toggle .track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--muted);
transition: transform 0.15s, background 0.15s;
}
.toggle[aria-pressed="true"] .track { background: var(--signal-soft); }
.toggle[aria-pressed="true"] .track::after { transform: translateX(12px); background: var(--signal); }
.error {
margin-top: 20px;
padding: 12px 16px;
background: var(--card);
border-left: 3px solid var(--alert);
font-size: 14px;
}
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
/* ---- stat tiles ---- */
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
margin-top: 24px;
}
.stat {
padding: 16px 18px;
background: var(--card);
border: 1px solid var(--rule);
}
.stat .label {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.stat .value {
margin-top: 4px;
font-family: var(--mono);
font-size: 30px;
font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.stat.accent .value { color: var(--signal); }
.stat .sub {
margin-top: 4px;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- board layout ---- */
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 20px;
margin-top: 20px;
align-items: start;
}
.section {
background: var(--card);
border: 1px solid var(--rule);
min-width: 0;
}
.section-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--rule);
}
.section-head h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.section-head .note {
margin-left: auto;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
/* ---- workload table ---- */
.table-scroll { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
padding: 9px 14px;
text-align: right;
white-space: nowrap;
}
th {
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--muted);
border-bottom: 1px solid var(--rule);
vertical-align: bottom;
}
th .swatch {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: var(--status-color, var(--muted));
vertical-align: 1px;
}
th:first-child, td:first-child {
text-align: left;
position: sticky;
left: 0;
background: var(--card);
z-index: 1;
}
td:first-child { font-weight: 500; }
tbody tr + tr td { border-top: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
tbody tr:hover td { background: var(--card-raised); }
td.num {
--heat: 0;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), transparent);
}
tbody tr:hover td.num { background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), var(--card-raised)); }
td.zero { color: var(--faint); }
/* totals and closed-today sit apart from the per-status columns */
td.total { font-weight: 600; background: none; }
th.closed, td.closed { border-right: 1px solid var(--rule); }
td.closed { color: var(--signal); background: none; }
td.closed.zero { color: var(--faint); }
tfoot td.closed { color: var(--ink); }
tfoot td {
border-top: 1px solid var(--rule);
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
background: none;
}
tfoot td:first-child { font-family: var(--font-ui); color: var(--muted); font-weight: 500; }
tr.flash td { animation: flash 2.4s ease-out; }
@keyframes flash {
from { background: var(--signal-soft); }
}
.table-empty {
padding: 36px 18px;
text-align: center;
color: var(--muted);
}
/* ---- feed ---- */
.feed {
max-height: 640px;
overflow-y: auto;
}
.ticket {
--age: 0;
display: grid;
grid-template-columns: 66px 1fr;
border-bottom: 1px solid var(--rule);
}
.feed .ticket:last-child { border-bottom: none; }
/* how long ago this closed, drained of color as it ages out of the hour */
.rail {
display: flex;
flex-direction: column;
justify-content: center;
padding: 12px 0 12px 12px;
border-left: 3px solid color-mix(in oklab, var(--signal), var(--rule) calc(var(--age) * 100%));
font-family: var(--mono);
font-size: 14px;
font-weight: 500;
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
}
.rail small {
font-size: 10px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.body { padding: 12px 14px 12px 4px; min-width: 0; }
.ticket-id {
margin: 0;
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
}
.byline {
margin: 2px 0 0;
font-size: 12px;
color: var(--muted);
}
.byline b { color: var(--ink); font-weight: 500; }
.ticket.new { animation: slide 0.35s ease-out; }
@keyframes slide {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
.empty {
padding: 40px 24px;
text-align: center;
color: var(--muted);
}
.empty p { margin: 0; }
body > footer {
width: 100%;
max-width: 1320px;
margin: auto auto 0;
padding: 0 24px 28px;
font-size: 11px;
}
body > footer p { margin: 0; }
body > footer p + p { margin-top: 4px; }
/* ---- weather ---- */
.side { display: grid; gap: 20px; min-width: 0; }
.weather-now {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 4px 12px;
padding: 12px 18px 14px 8px;
}
.weather-now img { width: 84px; height: 84px; margin: -6px 0; }
.weather-temp {
font-family: var(--mono);
font-size: 38px;
font-weight: 600;
line-height: 1;
}
.weather-desc { margin-top: 4px; font-size: 14px; text-transform: capitalize; }
.weather-details {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 2px 14px;
padding-left: 10px;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
.weather-details b { color: var(--ink); font-weight: 500; }
.forecast {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-top: 1px solid var(--rule);
}
.forecast-day {
padding: 10px 4px 12px;
text-align: center;
font-family: var(--mono);
font-size: 12px;
}
.forecast-day + .forecast-day { border-left: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
.forecast-day .name { color: var(--muted); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
.forecast-day img { display: block; width: 44px; height: 44px; margin: 0 auto; }
.forecast-day .lo { color: var(--muted); }
.forecast-day .pop { margin-top: 2px; font-size: 11px; color: hsl(205, 70%, 66%); }
/* ---- temporary: unassigned verification list (not for prod) ---- */
.verify {
margin-top: 20px;
background: var(--card);
border: 1px dashed var(--rule);
}
.verify summary {
padding: 12px 18px;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
cursor: pointer;
}
.verify summary:hover { color: var(--ink); }
.verify[open] summary { border-bottom: 1px dashed var(--rule); }
.verify .warn { padding: 10px 18px 0; font-size: 13px; color: var(--alert); }
.verify td, .verify th { text-align: left; }
/* ---- 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) {
.board { grid-template-columns: minmax(0, 1fr); }
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.feed { max-height: none; }
}
@media (max-width: 720px) {
.wrap { padding: 20px 16px 40px; }
body > footer { padding: 0 16px 24px; }
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stat .value { font-size: 24px; }
.actions { margin-left: 0; width: 100%; }
th, td { padding: 8px 10px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Help desk dashboard</h1>
<div class="status" id="status" data-auth="loading">
<span class="dot"></span><span id="statusText">Checking sign-in…</span>
</div>
<div class="actions">
<button class="toggle" id="soundToggle" aria-pressed="true" onclick="toggleSound()">
<span class="track"></span>Sound on close
</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>
<button class="quiet" id="signOutBtn" onclick="signOut(this)" hidden>Sign out</button>
</div>
</header>
<div class="meta" id="meta"></div>
<div id="main"></div>
</div>
<dialog id="settings" aria-labelledby="settingsTitle"></dialog>
<script>
const $ = id => document.getElementById(id);
const ding = new Audio('/ding.mp3');
ding.preload = 'auto';
ding.volume = 0.5;
function beep() {
ding.currentTime = 0;
ding.play().catch(() => {
});
}
// the ding is on by default; muting is remembered per browser
let soundOn = true;
try { soundOn = localStorage.getItem('sdp-sound') !== 'off'; } catch (err) {}
function toggleSound() {
soundOn = !soundOn;
try { localStorage.setItem('sdp-sound', soundOn ? 'on' : 'off'); } catch (err) {}
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
if (soundOn) beep();
}
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
let data = {
auth: 'loading', // loading, connected or disconnected (signed out)
tickets: [],
workload: {},
unassigned: {},
lastCheck: null,
lastError: null
};
let seen = new Set();
let firstLoad = true;
let flashTechs = new Set();
// the browser builds the closed feed now; the server only answers each check with the user's token
const feed = new Map();
let feedReady = false;
// bumped when settings change, so a check that started under the old settings is dropped
let generation = 0;
let timers = [];
let signInProblem = null;
const STATUS_TEXT = {
loading: 'Checking sign-in…',
disconnected: 'Signed out',
connected: 'Live'
};
// what /auth/callback means when it sends someone back with ?signin=...
const SIGNIN_PROBLEMS = {
denied: 'Zoho sign-in was cancelled or refused.',
expired: 'The sign-in took too long or was started in another tab. Try again.',
notallowed: "That Zoho account isn't allowed to use this dashboard. Sign in with your MCCS staff account.",
failed: "Zoho sign-in didn't complete. Try again, and check the server log if it keeps happening."
};
function elapsed(ms) {
const mins = Math.floor(ms / 60000);
if (mins < 1) return { value: 'now', label: 'just closed' };
if (mins < 60) return { value: mins + 'm', label: 'ago' };
const hrs = Math.floor(mins / 60);
return { value: hrs + 'h ' + (mins % 60) + 'm', label: 'ago' };
}
function clockTime(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function esc(text) {
return String(text ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
// every call carries the session cookie; a 401 means the sign-in is gone or was revoked
async function api(url, options) {
const res = await fetch(url, { credentials: 'same-origin', ...options });
if (res.status === 401) {
signedOut();
throw new Error('Signed out');
}
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Request failed (' + res.status + ')');
return body;
}
function signedOut() {
timers.forEach(clearInterval);
timers = [];
data.auth = 'disconnected';
render();
}
async function start() {
const params = new URLSearchParams(location.search);
if (params.has('signin')) {
signInProblem = SIGNIN_PROBLEMS[params.get('signin')] || SIGNIN_PROBLEMS.failed;
history.replaceState(null, '', location.pathname);
}
let session;
try {
session = await api('/api/session');
} catch (err) {
if (data.auth === 'disconnected') return;
$('statusText').textContent = 'Server unreachable';
setTimeout(start, 15000);
return;
}
Object.assign(data, session, { auth: 'connected' });
render();
checkClosed();
checkWorkload();
loadWeather();
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(checkWorkload, data.settings.workloadMinutes * 60 * 1000),
setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner
];
}
async function checkClosed() {
// the whole feed on first load, then a short window that overlaps the last check
const minutes = feedReady ? data.windowMinutes : data.settings.feedHours * 60;
const gen = generation;
try {
const result = await api('/api/closed?minutes=' + minutes);
if (gen !== generation) return;
for (const t of result.tickets) feed.set(String(t.id), t);
const cutoff = Date.now() - data.settings.feedHours * 60 * 60 * 1000;
for (const [id, t] of feed) {
if (t.closedAt < cutoff) feed.delete(id);
}
data.tickets = [...feed.values()].sort((a, b) => b.closedAt - a.closedAt);
data.lastCheck = result.checkedAt;
data.lastError = result.error;
if (!result.error) feedReady = true;
} catch (err) {
if (data.auth !== 'connected') return;
data.lastError = err.message;
data.lastCheck = Date.now();
}
render();
}
async function checkWorkload() {
const gen = generation;
try {
const result = await api('/api/workload');
if (gen !== generation) return;
data.workload = result.workload;
data.unassigned = result.unassigned;
} catch (err) {
if (data.auth !== 'connected') return;
data.workload = { ...data.workload, error: err.message, updatedAt: Date.now() };
}
render();
}
function render() {
// the watcher: anything we haven't seen before is a fresh close
const fresh = data.tickets.filter(t => !seen.has(t.id));
flashTechs = new Set(firstLoad ? [] : fresh.map(t => t.technician));
if (!firstLoad && fresh.length && soundOn) beep();
$('status').dataset.auth = data.auth;
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
const live = data.auth === 'connected';
$('checkBtn').hidden = !live;
$('settingsBtn').hidden = !live;
$('signOutBtn').hidden = !live;
$('user').hidden = !live;
const user = data.user || {};
$('user').textContent = user.name || user.email || 'Signed in';
$('user').title = user.email || '';
const wl = data.workload || {};
const s = data.settings || { sites: [], groups: [], technicians: [], hiddenPanels: [] };
$('meta').innerHTML = !live ? '' : [
'Closed tickets checked every ' + data.checkMinutes + ' min',
'Status counts every ' + s.workloadMinutes + ' min',
s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians',
s.sites.length ? 'Sites: ' + s.sites.join(', ') : '',
s.groups.length ? 'Groups: ' + s.groups.join(', ') : '',
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
let html = '';
if (data.auth === 'disconnected') {
html += `${signInProblem ? `<div class="error"><strong>Not signed in</strong>${esc(signInProblem)}</div>` : ''}
<div class="panel">
<h2>Sign in to the help desk dashboard</h2>
<p>Sign in with your own Zoho account, the one you use for ServiceDesk Plus. The dashboard
only reads requests. Your sign-in stays in this browser until you sign out or it expires.</p>
<a class="button" href="/auth/login">Sign in with Zoho</a>
</div>`;
}
if (data.lastError) {
html += `<div class="error"><strong>Last check failed</strong>${esc(data.lastError)}</div>`;
}
if (wl.error) {
html += `<div class="error"><strong>Status counts failed</strong>${esc(wl.error)}</div>`;
}
if (data.unassigned && data.unassigned.error) {
html += `<div class="error"><strong>Unassigned count failed</strong>${esc(data.unassigned.error)}</div>`;
}
if (data.auth === 'connected') {
const shown = panel => !s.hiddenPanels.includes(panel);
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)}
${side ? `<div class="side">${side}</div>` : ''}
</div>`;
html += renderUnassignedList(data.unassigned || {});
}
// keep the verification list open across the 5s re-renders
const verifyOpen = !!document.querySelector('.verify[open]');
$('main').innerHTML = html;
if (verifyOpen) document.querySelector('.verify').open = true;
for (const t of data.tickets) seen.add(t.id);
// stay quiet until the first full feed has drawn, so a page load doesn't ding for old closes
if (feedReady) firstLoad = false;
}
function renderStats(wl) {
const techs = wl.technicians || [];
const loaded = !!wl.updatedAt && !wl.error;
const open = techs.reduce((n, t) => n + t.open, 0);
const closedToday = techs.reduce((n, t) => n + t.closedToday, 0);
const hourAgo = Date.now() - 60 * 60 * 1000;
const lastHour = data.tickets.filter(t => t.closedAt >= hourAgo).length;
const busiest = techs.slice().sort((a, b) => b.open - a.open)[0];
const top = (wl.statuses || [])[0];
const tile = (label, value, sub, accent) => `<div class="stat${accent ? ' accent' : ''}">
<div class="label">${esc(label)}</div>
<div class="value">${esc(value)}</div>
<div class="sub">${esc(sub || ' ')}</div>
</div>`;
const un = data.unassigned || {};
const unLoaded = !!un.updatedAt && !un.error;
return '<div class="stats">'
+ tile('Unassigned requests', unLoaded ? un.count : '–', unLoaded ? 'checked ' + clockTime(un.updatedAt) + (un.truncated ? ', partial' : '') : '')
+ tile('Open requests', loaded ? open : '–', top ? top.total + ' ' + top.name : '')
+ tile('Closed today', loaded ? closedToday : '–', 'since midnight', true)
+ tile('Closed last hour', lastHour, data.tickets[0] ? 'latest #' + data.tickets[0].id : 'none yet', true)
+ tile('Most open', busiest && busiest.open ? busiest.open : '–', busiest && busiest.open ? busiest.name : '')
+ '</div>';
}
function renderWorkload(wl) {
// 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 note = wl.updatedAt
? 'Updated ' + clockTime(wl.updatedAt) + (wl.truncated ? ' · partial: ' + wl.truncated : '')
: 'Loading…';
let body;
if (!wl.updatedAt) {
body = '<div class="table-empty">Counting tickets…</div>';
} else if (!techs.length) {
body = '<div class="table-empty">No open tickets for the watched technicians.</div>';
} else {
const max = Math.max(1, ...techs.flatMap(t => statuses.map(s => t.counts[s.name] || 0)));
const cell = (n, cls) => n
? `<td class="num ${cls || ''}" style="--heat:${cls ? 0 : (n / max * 0.9 + 0.1).toFixed(3)}">${n}</td>`
: `<td class="num zero ${cls || ''}">·</td>`;
// totals come right after the name so they stay visible when the status columns scroll off
const head = '<tr><th scope="col">Technician</th>'
+ '<th scope="col" class="total">Total open</th><th scope="col" class="closed">Closed today</th>'
+ statuses.map(s => `<th scope="col"${s.color ? ` style="--status-color:${s.color}"` : ''}><span class="swatch"></span>${esc(s.name)}</th>`).join('')
+ '</tr>';
const rows = techs.map(t => `<tr${flashTechs.has(t.name) ? ' class="flash"' : ''}>
<td>${esc(t.name)}</td>
${cell(t.open, 'total')}
${cell(t.closedToday, 'closed')}
${statuses.map(s => cell(t.counts[s.name] || 0)).join('')}
</tr>`).join('');
const sum = key => techs.reduce((n, t) => n + t[key], 0);
const foot = '<tr><td>All technicians</td>'
+ `<td class="total">${sum('open')}</td><td class="closed">${sum('closedToday')}</td>`
+ statuses.map(s => `<td>${s.total}</td>`).join('')
+ '</tr>';
body = `<div class="table-scroll"><table>
<thead>${head}</thead>
<tbody>${rows}</tbody>
<tfoot>${foot}</tfoot>
</table></div>`;
}
return `<section class="section">
<div class="section-head"><h2>Tickets by technician</h2><span class="note">${esc(note)}</span></div>
${body}
</section>`;
}
const iconUrl = (code, size) => `https://openweathermap.org/img/wn/${code}@${size}x.png`;
function renderWeather() {
if (!weather || !weather.enabled) return '';
if (weather.temp === undefined) {
return `<section class="section">
<div class="section-head"><h2>Weather</h2></div>
<div class="table-empty">${weather.error ? 'Weather unavailable: ' + esc(weather.error) : 'Loading…'}</div>
</section>`;
}
const today = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
const days = (weather.forecast || []).map(d => `<div class="forecast-day">
<div class="name">${d.date === today ? 'Today' : new Date(d.date + 'T12:00').toLocaleDateString([], { weekday: 'short' })}</div>
${d.icon ? `<img src="${iconUrl(d.icon, 2)}" alt="${esc(d.condition || '')}">` : ''}
<div>${d.high}° <span class="lo">${d.low}°</span></div>
<div class="pop">${d.pop >= 20 ? d.pop + '%' : '&nbsp;'}</div>
</div>`).join('');
const note = (weather.error ? 'stale · ' : '') + 'as of ' + clockTime(weather.updatedAt);
return `<section class="section">
<div class="section-head"><h2>${esc(weather.place || 'Weather')}</h2><span class="note">${esc(note)}</span></div>
<div class="weather-now">
${weather.icon ? `<img src="${iconUrl(weather.icon, 4)}" alt="">` : '<span></span>'}
<div>
<div class="weather-temp">${weather.temp}°F</div>
<div class="weather-desc">${esc(weather.description || weather.condition || '')}</div>
</div>
<div class="weather-details">
<span>Feels <b>${weather.feelsLike}°</b></span>
<span>Wind <b>${weather.wind} mph</b></span>
<span>Humidity <b>${weather.humidity}%</b></span>
${weather.sunset ? `<span>Sunset <b>${clockTime(weather.sunset)}</b></span>` : ''}
</div>
</div>
${days ? `<div class="forecast">${days}</div>` : ''}
</section>`;
}
// Debug list for comparing against the ManageEngine "Central Region Unassigned" view.
// Verified matching on 2026-10-15; flip to true to bring it back.
const SHOW_UNASSIGNED_LIST = false;
function renderUnassignedList(un) {
if (!SHOW_UNASSIGNED_LIST || !un.updatedAt || un.error) return '';
const rows = (un.tickets || []).map(t => `<tr>
<td class="num">#${esc(t.id)}</td>
<td>${esc(t.status || '')}</td>
<td>${esc(t.site || '')}</td>
<td>${esc(t.group || '')}</td>
<td>${esc(t.created || '')}</td>
</tr>`).join('');
const warn = un.discarded
? `<div class="warn">The API returned ${un.discarded} extra request(s) that failed the local check, so the search criteria aren't matching the view exactly.</div>`
: '';
return `<details class="verify">
<summary>Verify: ${un.count} unassigned request${un.count === 1 ? '' : 's'} (temporary, click to expand)</summary>
${warn}
${rows
? `<div class="table-scroll"><table>
<thead><tr><th>ID</th><th>Status</th><th>Site</th><th>Group</th><th>Created</th></tr></thead>
<tbody>${rows}</tbody>
</table></div>`
: '<div class="table-empty">No unassigned requests.</div>'}
</details>`;
}
function renderFeed() {
const list = data.tickets.length
? '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>'
: '<div class="empty"><p>Nothing closed yet.</p></div>';
return `<section class="section">
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.settings.feedHours}h</span></div>
${list}
</section>`;
}
function renderTicket(ticket) {
const age = Date.now() - ticket.closedAt;
const e = elapsed(age);
// 0 = just closed, 1 = fully aged out (one hour on)
const fade = Math.min(age / (60 * 60 * 1000), 1).toFixed(3);
const isNew = !firstLoad && !seen.has(ticket.id);
return `<article class="ticket${isNew ? ' new' : ''}" style="--age:${fade}">
<div class="rail">
${esc(e.value)}
<small>${esc(e.label)}</small>
</div>
<div class="body">
<p class="ticket-id">#${esc(ticket.id)}</p>
<p class="byline"><b>${esc(ticket.technician)}</b> &middot; closed ${esc(ticket.closedDisplay || clockTime(ticket.closedAt))}</p>
</div>
</article>`;
}
async function checkNow(btn) {
btn.disabled = true;
btn.textContent = 'Refreshing...';
try {
await Promise.all([checkClosed(), checkWorkload()]);
} finally {
btn.disabled = false;
btn.textContent = 'Refresh now';
}
}
async function signOut(btn) {
btn.disabled = true;
try {
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
} finally {
location.reload();
}
}
let weather = null;
async function loadWeather() {
try {
weather = await api('/api/weather');
render();
} 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();
setInterval(render, 20000); // re-age the rails between checks
</script>
<footer>
<p>Design and Credits: <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span></p>
<p>Dashboard by <span style="color: hsl(28, 95%, 58%);">Skylar Grant</span> with <span style="color: hsl(28, 95%, 58%);">Claude</span> (Anthropic)</p>
</footer>
</body>
</html>