91f03c21e8
Addresses the screenshot-review feedback in #26. No framework or build step added — all of this stays within the existing plain HTML/CSS/ vanilla-JS + go:embed architecture. - Nav: re-enable the bottom tab bar that was already built and switched off (Queue/On-call/Alerts/Team + a "More" sheet for Stats/Admin/Account), replacing the hamburger on phone width. - Queue: chip counts, a scroll fade on the filter row, a "Triggered Xh ago" + severity label per row, a chevron on the team switcher so it reads as a dropdown. - On-call: collapse repeated same-person days into shift bars (week view and "your shifts" both), show the week as a date range with the ISO week number as secondary text, split "Current shift" out from "Next shifts" with "ends in Nd", a pill badge + row highlight for "you". - Incident detail: fix the actual bug behind the duplicate "acknowledged" timeline entries (acknowledgeIncident's UPDATE had no guard on the incident's current status, so acknowledging an already-acknowledged incident silently re-logged the event — now idempotent, with regression tests on both the authenticated route and the ntfy ack-button route). Relabel escalation re-pages so they don't look like the same page landing twice. Copy the primary action up near the top. Label the "···" button. Group the timeline by phase (triggered/acknowledged/resolved). Add an "at a glance" summary row (duration/severity/responsible) and collapse the group labels by default. - Team overview: reword the vague copy ("One owner." etc.) into plain labels. - Empty states: fill in missing icons/one-liners across queue, alerts, stats and the incident timeline. - CSS: fix card padding bugs, verify link contrast already passes AA, introduce a --fs-* type-scale token set and migrate the few genuinely isolated cases onto it (left sizes tied to a fixed shape, a deliberately prominent display, or a non-negotiable constraint like the iOS-zoom-prevention input size as documented exceptions rather than guess at a render this change can't see). Verified with the full fmt/lint/test/helm-lint gate, plus a live instance against the test DB with seeded incidents and schedule data to trace the on-call grouping and timeline phase-splitting logic against real API responses. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
214 lines
7.3 KiB
JavaScript
214 lines
7.3 KiB
JavaScript
// DOM helpers, the bottom sheet, confirmation and toasts.
|
||
|
||
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
|
||
// attributes; boolean true sets an empty attribute, false/null skips it.
|
||
export function h(tag, attrs = {}, ...children) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v == null || v === false) continue;
|
||
if (k === 'class') el.className = v;
|
||
else if (k === 'text') el.textContent = v;
|
||
else if (k === 'dataset') Object.assign(el.dataset, v);
|
||
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
|
||
else if (k in el && typeof v !== 'string') el[k] = v;
|
||
else el.setAttribute(k, v === true ? '' : v);
|
||
}
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
function append(el, children) {
|
||
for (const c of children.flat(Infinity)) {
|
||
if (c == null || c === false) continue;
|
||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||
}
|
||
}
|
||
|
||
export function clear(el, ...children) {
|
||
el.replaceChildren();
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
|
||
const ICONS = {
|
||
back: ['M15 18l-6-6 6-6'],
|
||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
||
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
|
||
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
|
||
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
|
||
check: ['M5 12.5l4.5 4.5L19 7'],
|
||
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
||
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
||
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
|
||
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
||
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
|
||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||
chevronLeft: ['M15 18l-6-6 6-6'],
|
||
chevronRight: ['M9 6l6 6-6 6'],
|
||
chevronDown: ['M6 9l6 6 6-6'],
|
||
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
|
||
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
|
||
};
|
||
|
||
const SVG = 'http://www.w3.org/2000/svg';
|
||
export function icon(name, cls = 'icon') {
|
||
const svg = document.createElementNS(SVG, 'svg');
|
||
svg.setAttribute('viewBox', '0 0 24 24');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.setAttribute('class', cls);
|
||
for (const d of ICONS[name] || []) {
|
||
let node;
|
||
if (d.startsWith('circle:')) {
|
||
const [cx, cy, r] = d.slice(7).split(',');
|
||
node = document.createElementNS(SVG, 'circle');
|
||
node.setAttribute('cx', cx);
|
||
node.setAttribute('cy', cy);
|
||
node.setAttribute('r', r);
|
||
} else if (d.startsWith('rect:')) {
|
||
const [x, y, w, hgt, rx] = d.slice(5).split(',');
|
||
node = document.createElementNS(SVG, 'rect');
|
||
node.setAttribute('x', x);
|
||
node.setAttribute('y', y);
|
||
node.setAttribute('width', w);
|
||
node.setAttribute('height', hgt);
|
||
if (rx) node.setAttribute('rx', rx);
|
||
} else {
|
||
node = document.createElementNS(SVG, 'path');
|
||
node.setAttribute('d', d);
|
||
}
|
||
svg.append(node);
|
||
}
|
||
return svg;
|
||
}
|
||
|
||
// ---------- sheet ----------
|
||
|
||
const sheet = () => document.getElementById('sheet');
|
||
let sheetResolve = null;
|
||
|
||
// openSheet shows content in the bottom sheet (a centred dialog on desktop)
|
||
// and resolves with whatever closeSheet is given, or null when dismissed.
|
||
export function openSheet(build) {
|
||
const dlg = sheet();
|
||
if (dlg.open) closeSheet(null);
|
||
const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' }));
|
||
append(inner, [build()]);
|
||
clear(dlg, inner);
|
||
dlg.showModal();
|
||
const first = dlg.querySelector('[autofocus]');
|
||
if (first) first.focus();
|
||
return new Promise((resolve) => {
|
||
sheetResolve = resolve;
|
||
});
|
||
}
|
||
|
||
export function closeSheet(value = null) {
|
||
const dlg = sheet();
|
||
const resolve = sheetResolve;
|
||
sheetResolve = null;
|
||
if (dlg.open) dlg.close();
|
||
if (resolve) resolve(value);
|
||
}
|
||
|
||
export function sheetIsOpen() {
|
||
return sheet().open;
|
||
}
|
||
|
||
export function initSheet() {
|
||
const dlg = sheet();
|
||
// A tap on the backdrop lands on the dialog element itself.
|
||
dlg.addEventListener('click', (e) => {
|
||
if (e.target === dlg) closeSheet(null);
|
||
});
|
||
dlg.addEventListener('cancel', (e) => {
|
||
e.preventDefault();
|
||
closeSheet(null);
|
||
});
|
||
}
|
||
|
||
// confirm asks a yes/no question in the sheet.
|
||
export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) {
|
||
return openSheet(() => [
|
||
h('h2', { class: 'sheet-title', text: title }),
|
||
text && h('p', { class: 'sheet-text', text }),
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }),
|
||
h('button', {
|
||
class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`,
|
||
type: 'button',
|
||
autofocus: true,
|
||
onclick: () => closeSheet(true),
|
||
text: confirmLabel,
|
||
}),
|
||
),
|
||
]).then((v) => v === true);
|
||
}
|
||
|
||
// ---------- toast ----------
|
||
|
||
let toastTimer = 0;
|
||
export function toast(message, kind = '') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = message;
|
||
el.className = `toast ${kind}`;
|
||
el.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => {
|
||
el.hidden = true;
|
||
}, kind === 'error' ? 5000 : 2500);
|
||
}
|
||
|
||
// ---------- misc ----------
|
||
|
||
export function badge(text, cls = '') {
|
||
return h('span', { class: `badge ${cls}`, text });
|
||
}
|
||
|
||
// Access granted by the identity provider's groups. The server refuses to edit
|
||
// it by hand (it would be undone at the next sign-in), so the controls that
|
||
// would try are disabled with this as their reason.
|
||
export const SSO_MANAGED = 'Managed by single sign-on. Change the person’s groups in the identity provider.';
|
||
|
||
export function ssoBadge(text = 'SSO') {
|
||
return h('span', { class: 'row-team sso', title: SSO_MANAGED, text });
|
||
}
|
||
|
||
export function labelChip(k, v) {
|
||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||
}
|
||
|
||
// One entry in a section's overview: a card that is a link, carrying the count
|
||
// only that section can state. Both the Admin tab and the Team tab open on one
|
||
// of these menus, and a menu item is a shape rather than a page's own idea.
|
||
// note is usually just a string, but can be an array of children instead —
|
||
// deadman's "healthy" note below pairs text with a status icon.
|
||
export function menuCard(href, label, count, note) {
|
||
return h('a', { class: 'card overview-item', href },
|
||
h('div', { class: 'overview-head' },
|
||
h('strong', { text: label }),
|
||
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
||
typeof note === 'string'
|
||
? h('p', { class: 'muted small', text: note })
|
||
: h('p', { class: 'muted small' }, note),
|
||
);
|
||
}
|
||
|
||
export function emptyState(title, text, iconName) {
|
||
return h('div', { class: 'empty' },
|
||
iconName && icon(iconName),
|
||
h('strong', { text: title }),
|
||
text && h('span', { text }),
|
||
);
|
||
}
|
||
|
||
export function spinner() {
|
||
return h('div', { class: 'empty' }, h('span', { class: 'spinner' }));
|
||
}
|