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>
263 lines
9.3 KiB
JavaScript
263 lines
9.3 KiB
JavaScript
// The incident queue: filter chips and a list of incident rows.
|
|
|
|
import * as api from './api.js';
|
|
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
|
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
|
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
|
import * as onboarding from './onboarding.js';
|
|
import { navigate } from './app.js';
|
|
|
|
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
|
|
const FILTERS = [
|
|
{ id: 'open', label: 'Open', query: { sort: 'severity' } },
|
|
{ id: 'triggered', label: 'Triggered', query: { status: 'triggered', sort: 'severity' } },
|
|
{ id: 'acknowledged', label: 'Acked', query: { status: 'acknowledged', sort: 'severity' } },
|
|
{ id: 'snoozed', label: 'Snoozed', query: { snoozed: 'true' } },
|
|
{ id: 'resolved', label: 'Resolved', query: { status: 'resolved' } },
|
|
{ id: 'archived', label: 'Archived', query: { status: 'resolved', archived: 'true' } },
|
|
];
|
|
|
|
const EMPTY = {
|
|
open: ['All clear', 'Nothing open right now.', 'checkCircle'],
|
|
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.', 'checkCircle'],
|
|
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.', 'checkCircle'],
|
|
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.', 'clock'],
|
|
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.', null],
|
|
archived: ['Nothing archived', 'Resolved incidents land here once archived.', 'archive'],
|
|
};
|
|
|
|
onboarding.onRerender(() => renderList());
|
|
// The queue used to keep its own team filter (a per-tab sessionStorage value,
|
|
// out of step with team.js's own picker); both now defer to the global
|
|
// selector's shared state, so re-render whenever it changes.
|
|
onTeamChange(() => {
|
|
renderChips();
|
|
refresh({ fresh: true });
|
|
});
|
|
|
|
let filter = loadFilter();
|
|
let items = null; // null while loading
|
|
let error = null;
|
|
let selected = null;
|
|
let cursor = -1; // keyboard position in the list
|
|
let built = false;
|
|
|
|
function loadFilter() {
|
|
try {
|
|
const f = sessionStorage.getItem('terdut.queue.filter');
|
|
if (FILTERS.some((x) => x.id === f)) return f;
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
return 'open';
|
|
}
|
|
|
|
function saveFilter() {
|
|
try {
|
|
sessionStorage.setItem('terdut.queue.filter', filter);
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
}
|
|
|
|
export function show(incidentID) {
|
|
selected = incidentID;
|
|
if (!built) {
|
|
renderChips();
|
|
built = true;
|
|
}
|
|
renderList();
|
|
}
|
|
|
|
export async function refresh({ fresh = false } = {}) {
|
|
const f = FILTERS.find((x) => x.id === filter);
|
|
const requested = filter;
|
|
try {
|
|
// The open list is already fetched for the badges; no need to ask twice.
|
|
// The cached open queue covers every team, so it can only be reused when
|
|
// no team filter is applied.
|
|
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
|
|
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
|
|
const result = cached ? state.open : await api.incidents(query);
|
|
await onboarding.load();
|
|
if (requested !== filter) return;
|
|
items = result;
|
|
error = null;
|
|
} catch (err) {
|
|
if (requested !== filter) return;
|
|
error = err.message;
|
|
}
|
|
// state.open (what the chip counts read) has just been refreshed too, by
|
|
// whichever caller updated it before calling here — app.js's poll, or the
|
|
// `cached` branch above.
|
|
renderChips();
|
|
renderList();
|
|
}
|
|
|
|
function setFilter(id) {
|
|
if (id === filter) return;
|
|
filter = id;
|
|
saveFilter();
|
|
items = null;
|
|
cursor = -1;
|
|
renderChips();
|
|
renderList();
|
|
refresh({ fresh: true });
|
|
}
|
|
|
|
// Counts for the three chips derivable from the open list already fetched
|
|
// for the badges — Snoozed/Resolved/Archived would need a request of their
|
|
// own, so those chips stay count-less for now.
|
|
function chipCount(id) {
|
|
const open = state.selectedTeamID == null
|
|
? state.open
|
|
: state.open.filter((i) => i.team_id === state.selectedTeamID);
|
|
if (id === 'open') return open.length;
|
|
if (id === 'triggered') return open.filter((i) => i.status === 'triggered').length;
|
|
if (id === 'acknowledged') return open.filter((i) => i.status === 'acknowledged').length;
|
|
return null;
|
|
}
|
|
|
|
function renderChips() {
|
|
const el = document.getElementById('queue-filters');
|
|
const chips = FILTERS.map((f) => {
|
|
const count = chipCount(f.id);
|
|
return h('button', {
|
|
class: 'chip',
|
|
type: 'button',
|
|
role: 'tab',
|
|
'aria-selected': String(f.id === filter),
|
|
onclick: () => setFilter(f.id),
|
|
text: f.label,
|
|
}, count != null && h('span', { class: 'count', text: String(count) }));
|
|
});
|
|
|
|
// Somebody in one team has nothing to choose between, so the row of team
|
|
// chips appears only when there is more than one. The default is all of
|
|
// them: the combined queue is the point.
|
|
if (state.teams.length > 1) {
|
|
chips.push(h('span', { class: 'chip-sep' }));
|
|
chips.push(h('button', {
|
|
class: 'chip',
|
|
type: 'button',
|
|
role: 'tab',
|
|
'aria-selected': String(state.selectedTeamID == null),
|
|
onclick: () => setSelectedTeam(null),
|
|
}, h('span', { class: 'team-dot' }), ' All teams'));
|
|
for (const team of state.teams) {
|
|
chips.push(h('button', {
|
|
class: 'chip',
|
|
type: 'button',
|
|
role: 'tab',
|
|
'aria-selected': String(team.id === state.selectedTeamID),
|
|
onclick: () => setSelectedTeam(team.id),
|
|
},
|
|
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
|
|
' ' + team.name,
|
|
));
|
|
}
|
|
}
|
|
|
|
// A scroll hint for the phone-width row, where the chips can run off the
|
|
// right edge with nothing to suggest there's more; the desktop sidebar
|
|
// wraps instead of scrolling (see .pane-list .chips), so this fades out
|
|
// there via CSS rather than being left out here.
|
|
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
|
|
|
|
clear(el, chips);
|
|
}
|
|
|
|
function renderList() {
|
|
const el = document.getElementById('queue-list');
|
|
const checklist = onboarding.card();
|
|
if (error && !items) {
|
|
clear(el, checklist, h('div', { class: 'load-error', text: error }));
|
|
return;
|
|
}
|
|
if (!items) {
|
|
clear(el, checklist, spinner());
|
|
return;
|
|
}
|
|
if (!items.length) {
|
|
const [title, text, iconName] = EMPTY[filter];
|
|
clear(el, checklist, emptyState(title, text, iconName));
|
|
return;
|
|
}
|
|
clear(el,
|
|
checklist,
|
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
|
items.map((inc, i) => row(inc, i)),
|
|
);
|
|
}
|
|
|
|
function row(inc, index) {
|
|
const snoozed = isFuture(inc.snoozed_until);
|
|
const resolved = inc.status === 'resolved';
|
|
|
|
let status;
|
|
if (resolved) status = badge('Resolved', 'st-resolved');
|
|
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
|
|
else if (inc.status === 'acknowledged') {
|
|
const by = inc.acknowledged_by_id === myID() ? 'you' : inc.acknowledged_by;
|
|
status = badge(`Acked${by ? ' · ' + by : ''}`, 'st-acknowledged');
|
|
}
|
|
else status = badge('Triggered', 'st-triggered');
|
|
|
|
let assignee = null;
|
|
if (inc.assigned_to_id != null) {
|
|
assignee = h('span', { text: inc.assigned_to_id === myID() ? '→ you' : `→ ${inc.assigned_to}` });
|
|
}
|
|
|
|
// The server already puts the group labels in the title; show only the rest.
|
|
const labels = labelSummary(Object.fromEntries(
|
|
Object.entries(inc.group_labels || {}).filter(([k, v]) => !inc.title.includes(`${k}=${v}`))));
|
|
// The team is shown only to somebody who is in more than one. For everybody
|
|
// else it is the same word on every row, which is noise rather than
|
|
// information.
|
|
const team = state.teams.length > 1 && inc.team_name
|
|
? h('span', { class: 'row-team', text: inc.team_name })
|
|
: null;
|
|
|
|
return h('a', {
|
|
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
|
|
href: `/incidents/${inc.id}`,
|
|
'aria-current': inc.id === selected ? 'true' : null,
|
|
dataset: { index: String(index) },
|
|
},
|
|
h('div', { class: 'row-title', text: inc.title }),
|
|
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
|
|
h('div', { class: 'row-meta' },
|
|
status,
|
|
// The left-border colour alone doesn't say what it means; spell it out
|
|
// too, same badge the incident detail page uses for severity.
|
|
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
|
|
assignee,
|
|
team,
|
|
labels && h('span', { class: 'labels', text: labels }),
|
|
),
|
|
);
|
|
}
|
|
|
|
// key handles j/k/enter on the list. Returns true when it used the key.
|
|
export function key(e) {
|
|
if (!items || !items.length) return false;
|
|
if (e.key === 'j' || e.key === 'ArrowDown') {
|
|
cursor = Math.min(items.length - 1, cursor + 1);
|
|
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
|
cursor = Math.max(0, cursor - 1);
|
|
} else if (e.key === 'Enter' && cursor >= 0) {
|
|
navigate(`/incidents/${items[cursor].id}`);
|
|
return true;
|
|
} else if (e.key === 'f') {
|
|
const i = FILTERS.findIndex((x) => x.id === filter);
|
|
setFilter(FILTERS[(i + 1) % FILTERS.length].id);
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
renderList();
|
|
const el = document.querySelector(`#queue-list [data-index="${cursor}"]`);
|
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
return true;
|
|
}
|