diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d6d4735..2d8f091 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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 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; } diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index b421064..e2700b8 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -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) {