// 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`; }