Files
Johan Sandoval 4cc810adf5 Jump to the middle; bold jump arrows; Chicago time; demo hours 6 AM-9 PM
- Jump sheet: Top / Middle / Bottom with the Card fields arrows; the Jump
  button uses the same bold arrows.
- Container timezone defaults to America/Chicago (TZ in Dockerfile/compose).
- Demo worker follows its own business hours (Demo.BusinessHours, default
  06:00-21:00 every day) with the midnight pitstop; the simulated cycle in
  the browser shows the real worker's next run.
2026-10-06 22:16:58 +00:00

216 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
/*
* USMT Dash - "Jump" for phones and tablets
*
* Scrolling through thousands of device cards is slow, so on narrow screens a round Jump
* button floats above the terminal bar on every page. It opens a sheet with:
* - Find a device: type part of a name and pick it; the page scrolls to that card and
* flashes it (nothing is filtered or hidden, unlike the search boxes).
* - Sections: every group on the page (USMT Status groups, Workbench stacks, the Overview
* list, the article list / article) with its count, to jump straight there.
* - Top / Bottom.
*/
(function () {
'use strict';
const narrow = window.matchMedia('(max-width: 1100px)');
let btn = null, sheet = null, backdrop = null;
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
const visible = e => !!(e && e.offsetParent !== null && e.getClientRects().length);
const currentPage = () => Array.from(document.querySelectorAll('.page')).find(p => p.style.display !== 'none');
const navH = () => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 60;
// Scroll so the element sits just below everything that sticks to the top
function scrollToEl(target, extra) {
const top = target.getBoundingClientRect().top + window.scrollY - navH() - (extra || 0) - 8;
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
// Sticky group header + filter bar above a card (the header is a sibling of the table or
// of one of its wrappers)
function stickyAbove(row) {
let h = 0;
const table = row.closest('table');
for (let e = table && table.parentElement, depth = 0; e && depth < 4; e = e.parentElement, depth++) {
const gh = e.querySelector(':scope > .group-header');
if (gh) { if (getComputedStyle(gh).position === 'sticky') h += gh.offsetHeight; break; }
}
const bar = table && table.previousElementSibling;
if (bar && bar.classList.contains('xf-bar')) h += bar.offsetHeight;
return h;
}
function flash(node) {
node.classList.remove('jump-flash');
void node.offsetWidth;
node.classList.add('jump-flash');
setTimeout(() => node.classList.remove('jump-flash'), 2200);
}
// A group header's title: a stack's name box, else its text without the buttons
function headerLabel(gh) {
const input = gh.querySelector('input');
if (input) return input.value.trim();
const c = gh.cloneNode(true);
c.querySelectorAll('button, div, .no-copy').forEach(n => n.remove());
return c.textContent.replace(/\s+/g, ' ').trim();
}
// ---------------------------------------------------------------------------------
// What's on the page
// ---------------------------------------------------------------------------------
function sections(page) {
const out = [];
page.querySelectorAll('.group-header').forEach(gh => {
if (!visible(gh)) return;
const label = headerLabel(gh);
if (label) out.push({ label, node: gh.closest('.table-wrapper, .stack-box, [id^="group-container-"]') || gh });
});
if (page.id === 'docs') {
const list = document.getElementById('docList'), art = document.getElementById('docToolbar');
if (visible(list)) out.push({ label: 'Article list', node: list.parentElement });
if (visible(art)) out.push({ label: 'Article', node: art });
}
if (page.id === 'overview') {
[['ov-issues', 'Issues to clear'], ['ov-grid', 'Status & locations'], ['ov-months', 'Deployment calendar']].forEach(([cls, label]) => {
const n = page.querySelector('.' + cls);
if (visible(n)) out.push({ label, node: n.closest('.ov-card') || n });
});
}
return out;
}
// Rows / cards whose device names match
function findDevices(page, q) {
q = q.trim().toUpperCase();
if (q.length < 2) return [];
const hits = [];
const seen = new Set();
page.querySelectorAll('table.master-table tbody tr').forEach(tr => {
if (hits.length >= 25 || !visible(tr)) return;
// device names are the bold cell text in every table (any naming scheme)
const names = Array.from(tr.querySelectorAll('td b')).map(b => b.textContent.trim().toUpperCase())
.concat(tr.cells[0] ? [tr.cells[0].textContent.trim().toUpperCase()] : []);
const name = names.find(n => n && n.length < 64 && n.includes(q));
if (!name) return;
const group = tr.closest('[id^="group-container-"], .stack-box, .table-wrapper');
const gh = group && group.querySelector('.group-header');
const where = gh ? headerLabel(gh).replace(/\s*\(\d+\)\s*$/, '') : '';
const key = name + '|' + where;
if (seen.has(key)) return;
seen.add(key);
hits.push({ name, where, tr });
});
return hits;
}
// ---------------------------------------------------------------------------------
// Sheet
// ---------------------------------------------------------------------------------
function close() {
if (sheet) sheet.remove();
if (backdrop) backdrop.remove();
sheet = backdrop = null;
}
function open() {
close();
const page = currentPage();
if (!page) return;
backdrop = el('div', 'xf-backdrop'); backdrop.onclick = close;
sheet = el('div', 'xf-menu xf-sheet jump-sheet');
sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Jump to');
const head = el('div', 'xf-head');
head.appendChild(el('span', 'xf-title', 'Jump to'));
const x = el('button', 'xf-x', '×'); x.type = 'button'; x.title = 'Close'; x.onclick = close;
head.appendChild(x);
sheet.appendChild(head);
const hasRows = !!page.querySelector('table.master-table tbody tr');
const results = el('div', 'jump-results');
if (hasRows) {
const search = el('input', 'xf-search jump-search');
search.type = 'search'; search.placeholder = 'Find a device (part of the name)...';
search.setAttribute('autocomplete', 'off'); search.setAttribute('autocapitalize', 'characters');
sheet.appendChild(search);
sheet.appendChild(results);
const show = () => {
results.textContent = '';
const q = search.value;
if (q.trim().length < 2) return;
const hits = findDevices(page, q);
if (!hits.length) { results.appendChild(el('div', 'jump-empty', 'No device on this page matches. (Filters may be hiding it.)')); return; }
hits.forEach(h => {
const b = el('button', 'jump-item'); b.type = 'button';
b.appendChild(el('b', null, h.name));
if (h.where) b.appendChild(el('span', null, h.where));
b.onclick = () => { close(); scrollToEl(h.tr, stickyAbove(h.tr)); setTimeout(() => flash(h.tr), 350); };
results.appendChild(b);
});
};
search.oninput = show;
search.onkeydown = e => { if (e.key === 'Enter') { const f = results.querySelector('.jump-item'); if (f) f.click(); } };
}
const secs = sections(page);
if (secs.length) {
sheet.appendChild(el('div', 'xf-group', 'Sections'));
const list = el('div', 'jump-sections');
secs.forEach(s => {
const b = el('button', 'jump-item jump-sec'); b.type = 'button';
b.textContent = s.label;
b.onclick = () => { close(); scrollToEl(s.node); };
list.appendChild(b);
});
sheet.appendChild(list);
}
// Top / Middle / Bottom (same bold arrows as Card fields)
const ends = el('div', 'xf-actions jump-ends');
const svg = d => '<svg viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const end = (icon, label, y) => {
const b = el('button', 'xf-cancel jump-end'); b.type = 'button';
b.innerHTML = svg(icon); b.appendChild(el('span', null, label));
b.onclick = () => { close(); window.scrollTo({ top: y(), behavior: 'smooth' }); };
ends.appendChild(b);
};
const maxY = () => document.documentElement.scrollHeight - window.innerHeight;
end('<path d="M8 13V3M3.5 7.5 8 3l4.5 4.5"/>', 'Top', () => 0);
end('<path d="M4.5 1.8 8 5l3.5-3.2M4.5 14.2 8 11l3.5 3.2M1.8 8h12.4"/>', 'Middle', () => Math.round(maxY() / 2));
end('<path d="M8 3v10M3.5 8.5 8 13l4.5-4.5"/>', 'Bottom', () => maxY());
sheet.appendChild(ends);
document.body.appendChild(backdrop);
document.body.appendChild(sheet);
const s = sheet.querySelector('.jump-search');
if (s) setTimeout(() => s.focus(), 50);
}
// ---------------------------------------------------------------------------------
// Button: phones / tablets only, on pages taller than a couple of screens
// ---------------------------------------------------------------------------------
function update() {
if (!btn) return;
const page = currentPage();
const long = document.documentElement.scrollHeight > window.innerHeight * 1.8;
btn.hidden = !narrow.matches || !page || !long;
}
function init() {
btn = el('button', 'jump-btn');
btn.type = 'button'; btn.title = 'Jump to a device or section'; btn.setAttribute('aria-label', 'Jump to a device or section');
// up + down arrows, drawn like the Card fields arrows
btn.innerHTML = '<svg viewBox="0 0 24 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 13V3M1.5 7.5 6 3l4.5 4.5"/><path d="M18 3v10M13.5 8.5 18 13l4.5-4.5"/></svg><span>Jump</span>';
btn.onclick = open;
btn.hidden = true;
document.body.appendChild(btn);
let t = null;
const later = () => { clearTimeout(t); t = setTimeout(update, 250); };
new MutationObserver(later).observe(document.body, { childList: true, subtree: true });
window.addEventListener('resize', later);
if (narrow.addEventListener) narrow.addEventListener('change', () => { close(); update(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && sheet) close(); });
update();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();