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>
218 lines
7.7 KiB
JavaScript
218 lines
7.7 KiB
JavaScript
// 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`;
|
||
}
|