Draw the rota as shift bars, and the escalation chain as a ladder
The rota put an initial on every day, so one person covering a week was seven identical chips and a legend to decode them. A shift is now one bar across its days with the person's name on it; a flat end with a chevron means it carries on across the row break. Days with nobody on call are a hatched amber bar while they can still be fixed and a quiet grey one once they are history, and the footer is a green banner when the month is covered or an amber one when it is not. Tapping a day still opens it: the bars ignore the pointer, so the tap reaches the cell underneath. The month title was monospace because its button borrowed .label, which belongs to label chips; it has its own class now, and a Today button sits beside the arrows. The escalation card was a five-column table. It is now the ladder it describes: a numbered step per level with its status and who it pages, the wait before the next level on its own line, and what happens after the last one at the bottom. With no fallback topic that last step is an amber callout, with an Add fallback button for owners that opens the editor on the field. That gap is what incident #36 hit. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
This commit is contained in:
+94
-19
@@ -718,7 +718,7 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.you-badge { margin-left: 6px; }
|
||||
|
||||
.week-nav { display: flex; align-items: center; gap: 4px; }
|
||||
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
|
||||
.week-nav .month-label { font-size: 15px; font-weight: 650; min-width: 8em; text-align: center; }
|
||||
/* The week-nav button that shows the date range, "28 Sep – 4 Oct", with the
|
||||
ISO week number as secondary text inside it — a separate class from
|
||||
.label above (team.js's month-nav uses that one) so its <small> isn't
|
||||
@@ -999,23 +999,32 @@ kbd {
|
||||
too alike down a column to read, so a day carries an initial in that
|
||||
person's colour and the legend underneath says whose. A shift is then a run
|
||||
of one colour, which is the shape the question actually has. */
|
||||
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
|
||||
.rota-grid { padding: 10px 12px 4px; }
|
||||
.rota-heads, .rota-row { display: grid; grid-template-columns: 2.4em repeat(7, minmax(0, 1fr)); column-gap: 2px; }
|
||||
.rota-wd {
|
||||
padding-bottom: 4px; text-align: center;
|
||||
padding-bottom: 6px; text-align: center;
|
||||
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.rota-row { grid-template-rows: 26px 34px; padding: 4px 0 6px; }
|
||||
.rota-row + .rota-row { border-top: 1px solid var(--border); }
|
||||
/* A day is a cell that spans both of its row's lines: the number on the first,
|
||||
the shift bar drawn over the second. The bar ignores the pointer, so a tap
|
||||
anywhere in the cell reaches the button. */
|
||||
.rota-day {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
min-height: 52px; padding: 6px 0 8px;
|
||||
grid-row: 1 / span 2;
|
||||
display: flex; justify-content: center; align-items: flex-start;
|
||||
min-width: 0; padding-top: 2px;
|
||||
border: 0; border-radius: var(--radius-sm); background: none;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
button.rota-day { cursor: pointer; }
|
||||
button.rota-day:hover { background: var(--surface-2); }
|
||||
button.rota-day:focus-visible, button.rota-week:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
/* The week number starts each row. Quiet by default, because it is a label
|
||||
first; an owner's tap on it is the second thing it does. */
|
||||
.rota-week {
|
||||
grid-column: 1; grid-row: 1 / span 2;
|
||||
display: grid; place-items: center;
|
||||
border: 0; border-radius: var(--radius-sm); background: none;
|
||||
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
@@ -1023,18 +1032,43 @@ button.rota-day:hover { background: var(--surface-2); }
|
||||
}
|
||||
button.rota-week { cursor: pointer; }
|
||||
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
.rota-week.current { color: var(--accent); font-weight: 700; }
|
||||
.rota-week.current { color: var(--text); font-weight: 700; }
|
||||
/* The sheet's row of who holds each day of the week. */
|
||||
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
|
||||
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
|
||||
.week-holder.past { opacity: 0.55; }
|
||||
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.rota-day.today { background: var(--accent-soft); }
|
||||
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
||||
.rota-day.past { opacity: 0.55; }
|
||||
.rota-num {
|
||||
min-width: 22px; height: 22px; padding: 0 3px; border-radius: 11px;
|
||||
text-align: center; line-height: 22px;
|
||||
color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Today is a filled number, a marker and not the accent, which means "you can
|
||||
act on this". */
|
||||
.rota-day.today .rota-num { background: var(--text); color: var(--bg); font-weight: 700; }
|
||||
/* The days either side of the month are real days and are drawn, but they
|
||||
belong to the month you are not looking at. */
|
||||
.rota-day.outside { opacity: 0.35; }
|
||||
.rota-day.outside .rota-num { opacity: 0.4; }
|
||||
|
||||
/* A shift: a run of consecutive days held by one person, in that person's
|
||||
colour (.rc1-.rc6 below). Rounded ends are where it really starts or stops;
|
||||
a flat end with a chevron carries on across the row break. */
|
||||
.rota-bar {
|
||||
grid-row: 2; align-self: center; z-index: 1; pointer-events: none;
|
||||
display: flex; align-items: center; min-width: 0; height: 28px; padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.rota-bar.past { opacity: 0.55; }
|
||||
.rota-bar.outside { opacity: 0.4; }
|
||||
.rota-bar.cont-l { margin-left: -2px; padding-left: 10px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
|
||||
.rota-bar.cont-r { margin-right: -2px; border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
||||
.rota-bar.cont-l::before { content: "‹"; margin-right: 4px; opacity: 0.7; }
|
||||
/* Nobody on call. Loud while it can still be fixed, quiet once it is history. */
|
||||
.rota-bar.gap {
|
||||
background: repeating-linear-gradient(135deg, transparent 0 5px, var(--warn-soft) 5px 10px);
|
||||
border: 1px dashed var(--warn); color: var(--warn);
|
||||
}
|
||||
.rota-bar.gap.past { background: none; border-color: var(--border-strong); color: var(--faint); }
|
||||
|
||||
.rota-chip {
|
||||
display: grid; place-items: center;
|
||||
@@ -1054,14 +1088,18 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
.rc5 { background: var(--teal-soft); color: var(--teal); }
|
||||
.rc6 { background: var(--pink-soft); color: var(--pink); }
|
||||
|
||||
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
|
||||
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
|
||||
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.rota-note:first-child { margin-top: 0; }
|
||||
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||
.rota-bulk .stacked-form { margin-top: 4px; }
|
||||
.rota-banner {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 14px; border-top: 1px solid var(--border); font-size: 14px;
|
||||
}
|
||||
.rota-banner .icon { width: 20px; height: 20px; }
|
||||
.rota-banner.ok { color: var(--ok); }
|
||||
.rota-banner.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
.rota-foot {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
padding: 12px 14px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.rota-bulk-form { padding: 0 14px 14px; }
|
||||
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
|
||||
.ladder-level {
|
||||
@@ -1074,6 +1112,43 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
|
||||
/* A target that would not wake anybody says why, in place: it is the reason a
|
||||
level is red, and the thing to go and fix. */
|
||||
/* The escalation ladder: a numbered step per level, the wait between levels as
|
||||
its own line, and what happens after the last one at the bottom. */
|
||||
.ladder { list-style: none; margin: 16px 0 0; padding: 0; }
|
||||
.step { display: grid; grid-template-columns: 32px minmax(0, 1fr); column-gap: 14px; }
|
||||
.step-rail { display: flex; flex-direction: column; align-items: center; }
|
||||
.step-node {
|
||||
display: grid; place-items: center; flex: none;
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
border: 2px solid var(--border-strong); background: var(--surface);
|
||||
font-size: 14px; font-weight: 750;
|
||||
}
|
||||
.step-node.st-ready { border-color: var(--ok); color: var(--ok); }
|
||||
.step-node.st-escalating { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
|
||||
.step-node.st-unreachable { border-color: var(--crit); color: var(--crit); background: var(--crit-soft); }
|
||||
.step-node.end { border-style: dashed; color: var(--muted); }
|
||||
.step-node.end.warn { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
|
||||
.step-node .icon { width: 16px; height: 16px; }
|
||||
.step-line { flex: 1; width: 2px; min-height: 8px; background: var(--border-strong); }
|
||||
.step-body { min-width: 0; padding-bottom: 4px; }
|
||||
.step-top { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 32px; }
|
||||
.step-pages { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; font-size: 14px; }
|
||||
.step-waiting { margin-top: 6px; font-size: 13px; color: var(--muted); }
|
||||
.step-waiting a { font-weight: 650; }
|
||||
.step-gap { display: flex; align-items: center; gap: 8px; padding: 6px 0 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
.step-gap .icon { width: 14px; height: 14px; }
|
||||
.callout {
|
||||
display: flex; align-items: flex-start; gap: 12px; margin-top: 4px;
|
||||
padding: 12px 14px; border: 1px solid var(--warn); border-radius: var(--radius);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.callout > .icon { flex: none; width: 20px; height: 20px; margin-top: 1px; color: var(--warn); }
|
||||
.callout strong { color: var(--warn); }
|
||||
.callout p { margin: 2px 0 10px; font-size: 14px; }
|
||||
.ladder-facts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.ladder-facts p { margin: 0; }
|
||||
.ladder-facts p + p { margin-top: 4px; }
|
||||
|
||||
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||||
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
|
||||
|
||||
|
||||
+142
-78
@@ -234,8 +234,8 @@ function overview() {
|
||||
// A month of it, as a grid. It used to be thirty rows of "date — username",
|
||||
// which is a rota spelled out one day at a time: the question asked of it is
|
||||
// "who has which stretch", and thirty names down a column is the one shape
|
||||
// that answer cannot be read in. So each day carries a coloured initial
|
||||
// instead, the legend says whose, and a shift becomes a run of one colour.
|
||||
// that answer cannot be read in. So a shift is drawn as one bar across its
|
||||
// days with the person's name on it.
|
||||
//
|
||||
// The same month laid out the same way as the on-call page's week, because it
|
||||
// is the same rota — heading and arrows outside the card, days inside it.
|
||||
@@ -274,22 +274,8 @@ function scheduleCard() {
|
||||
const today = isoDate(new Date());
|
||||
const month = monthStart.getMonth();
|
||||
|
||||
// Whose colours to explain, in the order the month meets them. Only the days
|
||||
// of this month count: a name that appears solely in the overhang belongs to
|
||||
// the month next door and would be explaining a chip nobody asked about.
|
||||
const seen = new Map();
|
||||
const cells = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const d = addDays(start, i);
|
||||
const key = isoDate(d);
|
||||
const e = byDate.get(key);
|
||||
const inMonth = d.getMonth() === month;
|
||||
// Every row starts with its week number, which is also the way to fill the
|
||||
// whole week at once.
|
||||
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
|
||||
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
|
||||
cells.push(dayCell(d, key, e, inMonth, today));
|
||||
}
|
||||
const rows = [];
|
||||
for (let w = 0; w < count / 7; w++) rows.push(weekRow(addDays(start, w * 7), byDate, today, month));
|
||||
|
||||
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
@@ -297,38 +283,104 @@ function scheduleCard() {
|
||||
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
|
||||
}
|
||||
|
||||
// The range form is the way to fill a whole shift at once, but it is not
|
||||
// what the page is for, so it stays folded away until asked for.
|
||||
let bulk = null;
|
||||
if (isOwner()) {
|
||||
const form = h('div', { class: 'rota-bulk-form', hidden: true }, assignForm());
|
||||
const toggle = h('button', {
|
||||
class: 'btn', type: 'button', 'aria-expanded': 'false',
|
||||
onclick: () => {
|
||||
form.hidden = !form.hidden;
|
||||
toggle.setAttribute('aria-expanded', String(!form.hidden));
|
||||
},
|
||||
}, icon('calendar'), 'Assign a range of days');
|
||||
bulk = [
|
||||
h('div', { class: 'rota-foot' },
|
||||
h('span', { class: 'muted small', text: 'Tap a day to change who holds it.' }), toggle),
|
||||
form,
|
||||
];
|
||||
}
|
||||
|
||||
const thisMonth = isoDate(monthStart) === isoDate(firstOfMonth(new Date()));
|
||||
return [
|
||||
h('div', { class: 'page-head' },
|
||||
h('h2', { text: 'On-call rota' }),
|
||||
h('div', { class: 'week-nav' },
|
||||
h('button', {
|
||||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||||
class: 'btn btn-icon week-arrow', type: 'button',
|
||||
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
|
||||
}, icon('chevronLeft')),
|
||||
h('span', { class: 'month-label', text: monthFmt.format(monthStart) }),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost label', type: 'button',
|
||||
title: 'Back to this month',
|
||||
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
|
||||
text: monthFmt.format(monthStart),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||||
class: 'btn btn-icon week-arrow', type: 'button',
|
||||
'aria-label': 'Next month', onclick: () => shiftMonth(1),
|
||||
}, icon('chevronRight')),
|
||||
h('button', {
|
||||
class: 'btn btn-sm', type: 'button', text: 'Today', disabled: thisMonth,
|
||||
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
|
||||
}),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'card' },
|
||||
h('div', { class: 'rota-grid' }, heads, cells),
|
||||
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
|
||||
// The range form is the way to fill a whole shift at once, but it is not
|
||||
// what the page is for, so it stays folded away under the month it edits.
|
||||
isOwner() && h('details', { class: 'rota-bulk' },
|
||||
h('summary', { text: 'Assign a range of days' }),
|
||||
assignForm()),
|
||||
h('div', { class: 'rota-grid' }, h('div', { class: 'rota-heads' }, heads), rows),
|
||||
coverNote(byDate),
|
||||
bulk,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// One Monday-to-Sunday row: the week number, a tappable cell per day, and the
|
||||
// shifts drawn over the days as bars. A bar is a run of consecutive days held
|
||||
// by the same person, so a week handed to one person is one bar with one name
|
||||
// on it. The bars ignore the pointer, so a tap lands on the day underneath.
|
||||
function weekRow(monday, byDate, today, month) {
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
|
||||
const keys = days.map(isoDate);
|
||||
const entries = keys.map((k) => byDate.get(k) || null);
|
||||
const uid = (i) => (entries[i] ? entries[i].user_id : null);
|
||||
const before = byDate.get(isoDate(addDays(monday, -1)));
|
||||
const after = byDate.get(isoDate(addDays(monday, 7)));
|
||||
|
||||
const cells = [weekCell(monday, byDate, today)];
|
||||
days.forEach((d, i) => {
|
||||
const cell = dayCell(d, keys[i], entries[i], d.getMonth() === month, today);
|
||||
cell.style.gridColumn = String(i + 2);
|
||||
cells.push(cell);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 7;) {
|
||||
let j = i;
|
||||
while (j + 1 < 7 && uid(j + 1) === uid(i)) j++;
|
||||
const entry = entries[i];
|
||||
// A flat end says the shift carries on past this row, not that it stops.
|
||||
const contL = !!entry && i === 0 && before?.user_id === entry.user_id;
|
||||
const contR = !!entry && j === 6 && after?.user_id === entry.user_id;
|
||||
cells.push(shiftBar(entry, i, j, { days, keys, month, today, contL, contR }));
|
||||
i = j + 1;
|
||||
}
|
||||
return h('div', { class: 'rota-row' }, cells);
|
||||
}
|
||||
|
||||
function shiftBar(entry, i, j, o) {
|
||||
const span = j - i + 1;
|
||||
const outside = o.days.slice(i, j + 1).every((d) => d.getMonth() !== o.month);
|
||||
const cls = ['rota-bar', entry ? colorClass(entry.user_id) : 'gap',
|
||||
outside && 'outside', o.keys[j] < o.today && 'past', o.contL && 'cont-l', o.contR && 'cont-r']
|
||||
.filter(Boolean).join(' ');
|
||||
// One day is too narrow for a name on a phone; the initial stands in and the
|
||||
// full name is in the day's own label underneath.
|
||||
const name = entry
|
||||
? `${entry.username}${entry.user_id === myID() ? ' (you)' : ''}`
|
||||
: (span >= 3 ? 'Nobody on call' : '');
|
||||
const bar = h('div', {
|
||||
class: cls, 'aria-hidden': 'true', title: name || 'Nobody on call',
|
||||
text: entry && span === 1 ? initial(entry.username) : name,
|
||||
});
|
||||
bar.style.gridColumn = `${i + 2} / span ${span}`;
|
||||
return bar;
|
||||
}
|
||||
|
||||
// The ISO week number at the start of a row. For an owner it is a button: one
|
||||
// tap fills the week, which is the way a rota is usually handed out — a person
|
||||
// takes a week, not seven separate days.
|
||||
@@ -347,15 +399,10 @@ function weekCell(monday, byDate, today) {
|
||||
}
|
||||
|
||||
function dayCell(d, key, e, inMonth, today) {
|
||||
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
|
||||
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today']
|
||||
.filter(Boolean).join(' ');
|
||||
const label = `${key} · ${e ? e.username : 'nobody'}`;
|
||||
const body = [
|
||||
h('span', { class: 'rota-num', text: String(d.getDate()) }),
|
||||
e
|
||||
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
|
||||
: h('span', { class: 'rota-chip none' }),
|
||||
];
|
||||
const body = [h('span', { class: 'rota-num', text: String(d.getDate()) })];
|
||||
// A member sees the same grid without the affordance, the way every other
|
||||
// control on this page is hidden rather than shown and refused.
|
||||
return isOwner()
|
||||
@@ -374,17 +421,7 @@ function colorClass(userID) {
|
||||
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
|
||||
}
|
||||
|
||||
function legend(seen) {
|
||||
if (!seen.size) return null;
|
||||
return h('div', { class: 'rota-legend' },
|
||||
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
|
||||
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
|
||||
h('span', { text: name }),
|
||||
id === myID() && h('span', { class: 'you', text: 'you' }),
|
||||
)));
|
||||
}
|
||||
|
||||
// The gap count, which is the one thing the grid states only by omission. Days
|
||||
// The gap count, which is the one thing the bars state only by omission. Days
|
||||
// already past are not counted: an empty Tuesday last week is history, not a
|
||||
// hole somebody still has to fill.
|
||||
function coverNote(byDate) {
|
||||
@@ -395,10 +432,14 @@ function coverNote(byDate) {
|
||||
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
|
||||
if (key >= today && !byDate.has(key)) gaps++;
|
||||
}
|
||||
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
|
||||
return h('p', { class: 'rota-note' },
|
||||
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
||||
' left this month with nobody on call.');
|
||||
if (gaps === 0) {
|
||||
return h('div', { class: 'rota-banner ok' }, icon('checkCircle'),
|
||||
h('span', { text: 'Every day left this month has somebody on call.' }));
|
||||
}
|
||||
return h('div', { class: 'rota-banner warn', role: 'status' }, icon('alertTriangle'),
|
||||
h('span', {},
|
||||
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
||||
' left this month with nobody on call.'));
|
||||
}
|
||||
|
||||
// One week, in the sheet: who holds each day of it, and one person to put on
|
||||
@@ -567,23 +608,49 @@ function escalationCard() {
|
||||
const esc = data.escalation || {};
|
||||
const levels = esc.levels || [];
|
||||
|
||||
const rows = levels.map((l) => h('tr', {},
|
||||
h('td', {}, h('strong', { text: `Level ${l.position}` })),
|
||||
h('td', {}, levelBadge(l.status)),
|
||||
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
|
||||
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
|
||||
h('td', { class: 'small' }, l.waiting?.length
|
||||
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
|
||||
: h('span', { class: 'muted', text: '—' })),
|
||||
));
|
||||
// The ladder as steps, top to bottom: each level, the wait before the next,
|
||||
// and what happens when the last one is not answered. The wait belongs to the
|
||||
// level above it ("then after 5m"), which is how the API states it.
|
||||
const steps = [];
|
||||
levels.forEach((l) => {
|
||||
steps.push(h('li', { class: 'step' },
|
||||
h('div', { class: 'step-rail' },
|
||||
h('span', { class: `step-node st-${l.status}`, text: String(l.position) }),
|
||||
h('span', { class: 'step-line' })),
|
||||
h('div', { class: 'step-body' },
|
||||
h('div', { class: 'step-top' }, h('strong', { text: `Level ${l.position}` }), levelBadge(l.status)),
|
||||
h('div', { class: 'step-pages' }, ...l.targets.map(targetLine)),
|
||||
l.waiting?.length > 0 && h('div', { class: 'step-waiting' }, 'Waiting now: ',
|
||||
...l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])))));
|
||||
steps.push(h('li', { class: 'step step-wait' },
|
||||
h('div', { class: 'step-rail' }, h('span', { class: 'step-line' })),
|
||||
h('div', { class: 'step-gap' }, icon('clock', 'icon'), `then after ${duration(l.timeout_seconds * 1000)}`)));
|
||||
});
|
||||
|
||||
if (levels.length) {
|
||||
steps.push(h('li', { class: 'step' },
|
||||
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` },
|
||||
icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))),
|
||||
h('div', { class: 'step-body' },
|
||||
h('div', { class: 'step-top' },
|
||||
h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })),
|
||||
esc.fallback_topic
|
||||
? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.')
|
||||
: h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'),
|
||||
h('div', {},
|
||||
h('strong', { text: 'No fallback topic' }),
|
||||
h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }),
|
||||
}, icon('plus'), 'Add fallback'))))));
|
||||
}
|
||||
|
||||
const facts = [];
|
||||
if (levels.length) {
|
||||
const n = esc.repeat_count || 0;
|
||||
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
|
||||
facts.push(esc.fallback_topic
|
||||
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
|
||||
: 'No fallback topic: after the last level the chain just ends.');
|
||||
facts.push(n
|
||||
? `Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`
|
||||
: 'Nothing repeats: the ladder runs once.');
|
||||
facts.push(esc.last_escalated_at
|
||||
? ['Last escalated ',
|
||||
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
|
||||
@@ -595,23 +662,18 @@ function escalationCard() {
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Escalation' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', onclick: openLadderEditor,
|
||||
class: 'btn btn-primary', type: 'button', onclick: () => openLadderEditor(),
|
||||
text: levels.length ? 'Edit ladder' : 'Set up ladder',
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||||
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||||
levels.length
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
|
||||
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
|
||||
h('tbody', {}, rows)))
|
||||
? h('ol', { class: 'ladder' }, steps)
|
||||
: h('p', { class: 'muted' },
|
||||
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||||
'reminder interval and nobody else is woken.'),
|
||||
...facts.map((f) => h('p', { class: 'muted small' }, f)),
|
||||
facts.length > 0 && h('div', { class: 'ladder-facts' }, ...facts.map((f) => h('p', { class: 'muted small' }, f))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,7 +681,7 @@ function escalationCard() {
|
||||
// it wholesale: the levels are an order, and patching one rung would leave the
|
||||
// numbering of the others undecided. The draft lives in the sheet, so a poll of
|
||||
// the page underneath cannot throw away half an edit.
|
||||
function openLadderEditor() {
|
||||
function openLadderEditor({ focusFallback = false } = {}) {
|
||||
const esc = data.escalation || {};
|
||||
const draft = {
|
||||
repeat_count: esc.repeat_count || 0,
|
||||
@@ -631,6 +693,7 @@ function openLadderEditor() {
|
||||
};
|
||||
|
||||
const body = h('div', { class: 'ladder-editor' });
|
||||
let fallbackInput = null;
|
||||
const problem = h('p', { class: 'load-error', hidden: true });
|
||||
|
||||
const paint = () => {
|
||||
@@ -670,7 +733,7 @@ function openLadderEditor() {
|
||||
value: String(draft.repeat_count),
|
||||
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
|
||||
});
|
||||
const fallback = h('input', {
|
||||
const fallback = fallbackInput = h('input', {
|
||||
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
|
||||
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
||||
});
|
||||
@@ -701,6 +764,7 @@ function openLadderEditor() {
|
||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||||
save),
|
||||
]);
|
||||
if (focusFallback) requestAnimationFrame(() => fallbackInput?.focus());
|
||||
}
|
||||
|
||||
function targetRow(level, target, index, repaint) {
|
||||
|
||||
Reference in New Issue
Block a user