Files
terdut-server/internal/web/static/js/oncall.js
T
Niklas Ye 91f03c21e8
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 18s
CI / test (pull_request) Successful in 4m49s
Web: visual design pass (issue #26)
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>
2026-10-03 09:01:54 +02:00

218 lines
7.7 KiB
JavaScript
Raw 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.
// On-call: who is on duty now, the week around it, and your own next shifts.
// Read-only for now; the TUI edits the schedule.
//
// One team's rota at a time — the viewer's first team, since a viewer in one
// team has nothing to choose between. "On call now" is the exception and shows
// every team the viewer is in, because somebody on two rotas wants both.
import * as api from './api.js';
import { h, clear, badge, icon, spinner } from './ui.js';
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
import { myID, currentTeam } from './state.js';
const view = () => document.getElementById('view-oncall');
let weekStart = mondayOf(new Date());
let data = null;
let error = null;
const dayName = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
const dayDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
export function show() {
if (!data) clear(view(), spinner());
refresh();
}
export async function refresh() {
const start = weekStart;
const today = new Date();
try {
const team = currentTeam();
if (!team) {
data = { now: [], week: [], upcoming: [] };
error = null;
render();
return;
}
const [now, week, upcoming] = await Promise.all([
api.onCallNow(),
api.schedule(team.id, isoDate(start), isoDate(addDays(start, 6))),
api.schedule(team.id, isoDate(today), isoDate(addDays(today, 60))),
]);
if (start !== weekStart) return;
data = { now, week, upcoming };
error = null;
} catch (err) {
error = err.message;
}
render();
}
function shiftWeek(n) {
weekStart = addDays(weekStart, 7 * n);
refresh();
}
function render() {
if (!data) {
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
clear(view(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
nowCard(),
weekCard(),
myShifts(),
);
}
function you(userID) {
return userID === myID() ? badge('you', 'plain st-oncall you-badge') : null;
}
// One card per team with somebody on call, and a single empty card when there
// is nobody anywhere. The team's name is shown only when the viewer is in more
// than one, so the common case reads exactly as it did before teams existed.
function nowCard() {
const entries = data.now || [];
const showTeam = entries.length > 1;
if (entries.length === 0) {
return h('div', { class: 'card now-card' },
h('div', { class: 'avatar none', text: '–' }),
h('div', {},
h('div', { class: 'now-label', text: 'On call now' }),
h('div', { class: 'now-name', text: 'Nobody' }),
),
);
}
return h('div', {}, ...entries.map((n) =>
h('div', { class: 'card now-card' },
h('div', { class: 'avatar', text: initial(n.username) }),
h('div', {},
h('div', {
class: 'now-label',
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
}),
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
),
)));
}
// Groups the week's 7 days into runs held by the same person (or the same
// empty slot) — the week's own version of the consecutive-day grouping
// myShifts does for a single person's own dates, below. Seven identical rows
// for one person all week collapses to the one bar this way.
function weekRuns(byDate) {
const runs = [];
for (let i = 0; i < 7; i++) {
const date = isoDate(addDays(weekStart, i));
const e = byDate.get(date) || null;
const uid = e ? e.user_id : null;
const last = runs[runs.length - 1];
if (last && last.uid === uid) last.to = date;
else runs.push({ uid, entry: e, from: date, to: date });
}
return runs;
}
function weekCard() {
const byDate = new Map(data.week.map((e) => [e.date, e]));
const today = isoDate(new Date());
const mine = myID();
const days = weekRuns(byDate).map((r) => {
const single = r.from === r.to;
const cls = [
'day',
single ? '' : 'range',
r.from <= today && today <= r.to ? 'today' : '',
r.to < today ? 'past' : '',
r.uid === mine ? 'mine' : '',
].filter(Boolean).join(' ');
const label = single
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
: [h('span', {
class: 'day-range',
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
})];
return h('li', { class: cls },
...label,
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
);
});
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
return [
h('div', { class: 'page-head' },
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
h('div', { class: 'week-nav' },
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
icon('chevronLeft')),
h('button', {
class: 'btn btn-ghost week-label',
type: 'button',
title: 'Back to this week',
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
icon('chevronRight')),
),
),
h('ul', { class: 'card days' }, days),
];
}
// myShifts groups your upcoming dates into runs of consecutive days, then
// splits off the one you're already in — listing it again under "Next
// shifts" told people they hadn't started a shift they were already on.
function myShifts() {
const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort();
const runs = [];
for (const date of mine) {
const last = runs[runs.length - 1];
if (last && isoDate(addDays(parse(last.to), 1)) === date) last.to = date;
else runs.push({ from: date, to: date });
}
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
const label = (r) => (r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}`);
const today = isoDate(new Date());
const current = runs[0] && runs[0].from <= today ? runs[0] : null;
const next = current ? runs.slice(1) : runs;
const currentCard = current ? [
h('div', { class: 'page-head' }, h('h2', { text: 'Current shift' })),
h('div', { class: 'card' },
h('ul', { class: 'shift-list' }, h('li', {},
h('span', { text: label(current) }),
h('span', { class: 'muted', text: `ends in ${duration(addDays(parse(current.to), 1) - Date.now())}` })))),
] : [];
return [
...currentCard,
h('div', { class: 'page-head' }, h('h2', { text: current ? 'Next shifts' : 'Your next shifts' })),
h('div', { class: 'card' },
next.length
? h('ul', { class: 'shift-list' }, next.slice(0, 8).map((r) =>
h('li', {},
h('span', { text: label(r) }),
h('span', { class: 'muted', text: days(r) })),
))
: h('div', {
class: 'empty',
text: current ? 'Nothing else scheduled in the next 60 days.' : 'Nothing scheduled in the next 60 days.',
})),
];
}
function parse(s) {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
function days(r) {
const n = Math.round((parse(r.to) - parse(r.from)) / 86400000) + 1;
return n === 1 ? '1 day' : `${n} days`;
}