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>
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
// every team the viewer is in, because somebody on two rotas wants both.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, icon, spinner } from './ui.js';
|
||||
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.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');
|
||||
@@ -68,7 +68,7 @@ function render() {
|
||||
}
|
||||
|
||||
function you(userID) {
|
||||
return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null;
|
||||
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
|
||||
@@ -99,20 +99,48 @@ function nowCard() {
|
||||
)));
|
||||
}
|
||||
|
||||
// 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 days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(weekStart, i);
|
||||
const key = isoDate(d);
|
||||
const e = byDate.get(key);
|
||||
days.push(h('li', { class: `day ${key === today ? 'today' : ''} ${key < today ? 'past' : ''}` },
|
||||
h('span', { class: 'day-name', text: dayName.format(d) }),
|
||||
h('span', { class: 'day-date', text: dayDate.format(d) }),
|
||||
h('span', { class: `day-who ${e ? '' : 'nobody'}` }, e ? e.username : 'nobody', e && you(e.user_id)),
|
||||
));
|
||||
}
|
||||
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' },
|
||||
@@ -121,12 +149,12 @@ function weekCard() {
|
||||
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 label',
|
||||
class: 'btn btn-ghost week-label',
|
||||
type: 'button',
|
||||
title: 'Back to this week',
|
||||
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
||||
text: `Week ${isoWeek(weekStart)}`,
|
||||
}),
|
||||
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')),
|
||||
),
|
||||
@@ -135,7 +163,9 @@ function weekCard() {
|
||||
];
|
||||
}
|
||||
|
||||
// myShifts groups your upcoming dates into runs of consecutive 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 = [];
|
||||
@@ -145,16 +175,34 @@ function myShifts() {
|
||||
else runs.push({ from: date, to: date });
|
||||
}
|
||||
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
|
||||
return [
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Your next shifts' })),
|
||||
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' },
|
||||
runs.length
|
||||
? h('ul', { class: 'shift-list' }, runs.slice(0, 8).map((r) =>
|
||||
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: r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}` }),
|
||||
h('span', { text: label(r) }),
|
||||
h('span', { class: 'muted', text: days(r) })),
|
||||
))
|
||||
: h('div', { class: 'empty', text: 'Nothing scheduled in the next 60 days.' })),
|
||||
: h('div', {
|
||||
class: 'empty',
|
||||
text: current ? 'Nothing else scheduled in the next 60 days.' : 'Nothing scheduled in the next 60 days.',
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user