diff --git a/README.md b/README.md index c08d0ce..bf70c4c 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,12 @@ membership. An owner edits it; a member sees the same page read-only, because the server refuses their writes anyway. Somebody in more than one team picks between them at the top. +The rota is a month at a time, one coloured initial per day with a legend +underneath, and it says how many days are left uncovered — the question a rota +is read for is who holds which stretch, and a run of one colour answers it +where a list of dates does not. An owner taps a day to hand it to somebody or +empty it, and fills a whole shift from the range form folded in below. + The **Admin** tab appears only for a system administrator, and holds what belongs to the whole server rather than to one team. It has three sub-sections, each with a URL of its own and a strip across the top to move between them: diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d612d55..8002803 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -31,6 +31,14 @@ --snooze: #6b5bd2; --snooze-soft: #efedfb; + /* Two hues that mean nothing on their own. The rota needs six colours to + tell six people apart and the palette above only has four that are not + already an alarm. */ + --teal: #0f7d8c; + --teal-soft: #e3f4f6; + --pink: #b3427e; + --pink-soft: #fbe8f2; + --radius: 10px; --radius-sm: 6px; --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%); @@ -72,6 +80,11 @@ --snooze: #a89bff; --snooze-soft: #262245; + --teal: #4fc2d4; + --teal-soft: #0f2e33; + --pink: #f07fb8; + --pink-soft: #3a1c2d; + --shadow: 0 1px 2px rgb(0 0 0 / 40%); --shadow-lg: 0 16px 40px rgb(0 0 0 / 55%); } @@ -729,6 +742,60 @@ kbd { .stacked-form input.wide { min-width: min(420px, 100%); } .team-picker { margin-top: 8px; max-width: 100%; } +/* The rota, a month at a time. A name is too wide to print thirty times and + 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: repeat(7, 1fr); gap: 2px; padding: 10px; } +.rota-wd { + padding-bottom: 4px; text-align: center; + color: var(--muted); font-size: 11px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.04em; +} +.rota-day { + display: flex; flex-direction: column; align-items: center; gap: 4px; + min-height: 52px; padding: 6px 0 8px; + 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); } +.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; } +/* 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-chip { + display: grid; place-items: center; + width: 26px; height: 26px; border-radius: 50%; + font-size: 12px; font-weight: 750; text-transform: uppercase; +} +/* An empty day is a dot rather than a hole, and keeps the chip's box so the + rows stay on one baseline. */ +.rota-chip.none { width: 8px; height: 8px; margin: 9px; background: var(--border-strong); } + +/* Six colours, then they repeat; the initial inside still tells two people + apart. Deliberately not the severity palette — nothing here is critical. */ +.rc1 { background: var(--accent-soft); color: var(--accent); } +.rc2 { background: var(--ok-soft); color: var(--ok); } +.rc3 { background: var(--snooze-soft); color: var(--snooze); } +.rc4 { background: var(--warn-soft); color: var(--warn); } +.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; } +.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; } + .ladder-level { border-left: 3px solid var(--border-strong); padding: 8px 0 8px 12px; margin: 12px 0; diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index 2da21ea..8ae2dee 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -10,9 +10,9 @@ // than no form, but it is not the thing enforcing anything. import * as api from './api.js'; -import { h, clear, spinner, confirm } from './ui.js'; -import { state, currentTeam, users as allUsers } from './state.js'; -import { isoDate, addDays } from './format.js'; +import { h, clear, spinner, confirm, icon, openSheet, closeSheet } from './ui.js'; +import { state, currentTeam, users as allUsers, myID } from './state.js'; +import { isoDate, addDays, mondayOf, initial } from './format.js'; const view = () => document.getElementById('view-team'); @@ -39,6 +39,7 @@ export async function refresh() { return; } teamID = team.id; + const grid = gridDays(); try { // A member may read all of this; only the writes are owner-only. const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([ @@ -46,7 +47,7 @@ export async function refresh() { api.integrations(team.id), api.escalation(team.id), api.deadman(team.id), - api.schedule(team.id, isoDate(new Date()), isoDate(addDays(new Date(), 30))), + api.schedule(team.id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))), allUsers(), ]); data = { team, members, integrations, escalation, deadman, schedule, users }; @@ -104,30 +105,200 @@ function teamPicker() { // The rota is one person per UTC day. The on-call page shows it; this is where // it is set, which until now was the TUI's job and the TUI cannot do it any // more. -function scheduleCard() { - const rows = (data.schedule || []).map((e) => - h('tr', {}, - h('td', { text: e.date }), - h('td', {}, h('strong', { text: e.username })), - h('td', {}, isOwner() && h('button', { - class: 'btn-sm danger', type: 'button', text: 'Clear', - onclick: () => act(() => api.unassignSchedule(teamID, e.id)), - })), - )); +// +// 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. +// +// 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. - return h('div', { class: 'card' }, - h('h2', { text: 'On-call rota' }), - h('p', { class: 'muted small', text: 'One person per UTC day, for the next 30 days.' }), - rows.length - ? h('table', { class: 'admin-table' }, h('tbody', {}, rows)) - : h('p', { class: 'muted', text: 'Nobody is scheduled.' }), - isOwner() && assignForm(), - ); +const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }); +const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' }); +const longDayFmt = new Intl.DateTimeFormat(undefined, { + weekday: 'long', day: 'numeric', month: 'long', +}); + +let monthStart = firstOfMonth(new Date()); + +function firstOfMonth(d) { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +// The grid runs Monday to Sunday, so it starts before the 1st and ends after +// the last. Both overhangs are fetched and drawn: a shift that begins on the +// 30th is a fact about this month even though the days it runs into are not. +function gridDays() { + const start = mondayOf(monthStart); + const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0); + const span = Math.round((last - start) / 86400000) + 1; + return { start, count: Math.ceil(span / 7) * 7 }; +} + +function shiftMonth(n) { + monthStart = new Date(monthStart.getFullYear(), monthStart.getMonth() + n, 1); + refresh(); +} + +function scheduleCard() { + const { start, count } = gridDays(); + const byDate = new Map((data.schedule || []).map((e) => [e.date, e])); + 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; + if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username); + cells.push(dayCell(d, key, e, inMonth, today)); + } + + const heads = []; + for (let i = 0; i < 7; i++) { + // Any Monday will do; this one is a Monday. + heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) })); + } + + 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', + 'aria-label': 'Previous month', onclick: () => shiftMonth(-1), + }, icon('chevronLeft')), + 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', + 'aria-label': 'Next month', onclick: () => shiftMonth(1), + }, icon('chevronRight')), + ), + ), + 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()), + ), + ]; +} + +function dayCell(d, key, e, inMonth, today) { + const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past'] + .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' }), + ]; + // 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() + ? h('button', { + class: cls, type: 'button', title: label, 'aria-label': label, + onclick: () => daySheet(key, e), + }, body) + : h('div', { class: cls, title: label }, body); +} + +// A colour per person, taken from their place in the member list so that it +// holds still as you page between months. Somebody who holds days but has +// since left the team is not in that list and falls back to their id. +function colorClass(userID) { + const i = (data.members || []).findIndex((m) => m.user_id === 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 +// already past are not counted: an empty Tuesday last week is history, not a +// hole somebody still has to fill. +function coverNote(byDate) { + const today = isoDate(new Date()); + const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0).getDate(); + let gaps = 0; + for (let day = 1; day <= last; day++) { + 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.'); +} + +// One day, in the sheet: who has it, who should, and the way to empty it. This +// is where the per-row Clear button went — the grid has no room for thirty of +// them, and the day you want to change is the one you just tapped. +function daySheet(date, entry) { + const who = memberSelect(entry ? entry.user_id : undefined); + openSheet(() => [ + h('h2', { class: 'sheet-title', text: longDayFmt.format(parseISO(date)) }), + h('p', { class: 'sheet-text', text: entry ? `${entry.username} is on call.` : 'Nobody is on call.' }), + h('label', { class: 'sheet-pick' }, 'On call ', who), + h('div', { class: 'sheet-actions' }, + entry && h('button', { + class: 'btn btn-danger', type: 'button', text: 'Clear', + onclick: () => { closeSheet(); act(() => api.unassignSchedule(teamID, entry.id)); }, + }), + h('button', { + class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign', + // replace, where the range form asks first: the sheet has just named + // whoever holds the day, so taking it from them is the thing that was + // asked for rather than something to be warned about. + onclick: () => { + closeSheet(); + act(() => api.assignSchedule(teamID, Number(who.value), [date], true)); + }, + }), + ), + ]); +} + +function parseISO(s) { + const [y, m, d] = s.split('-').map(Number); + return new Date(y, m - 1, d); } function assignForm() { const who = memberSelect(); - const from = h('input', { type: 'date', required: true, value: isoDate(new Date()) }); + // The form opens on the month above it rather than on today: it is folded + // into that month's card, and paging to March to fill March and being handed + // today's date would be the card and the form disagreeing about the subject. + const now = new Date(); + const sameMonth = monthStart.getFullYear() === now.getFullYear() + && monthStart.getMonth() === now.getMonth(); + const from = h('input', { + type: 'date', required: true, value: isoDate(sameMonth ? now : monthStart), + }); const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' }); const replace = h('input', { type: 'checkbox' });