536dbcaebe
Users now have an avatar. The account page takes an image, lets the user
drag and zoom it under a circular mask, and uploads exactly what the circle
shows as a 256x256 PNG. The picture replaces the initial-letter circle in
the sidebar, on the account page, on the on-call hero and week strip, on
the schedule's day chips and in the incident assign menu; someone without
one keeps the initial.
The picture lives in Postgres (user_avatars, bytea) rather than on a
volume: the chart runs two replicas on a read-only root filesystem, and
the k8up pg_dump backup then covers it without any chart or operator
change. The server does not trust the client's crop. It decodes the PNG or
JPEG, checks the declared size before decoding, centre-crops to a square,
scales to 256 and re-encodes, which drops EXIF and anything that is not
pixels. Standard library only; WebP is cropped by the browser and arrives
as PNG, a raw WebP sent to the API is refused.
GET/PUT/DELETE /api/users/{id}/avatar: anyone signed in may read, only the
owner or an administrator may change. /api/me and /api/users gain
avatar_version, absent when there is no picture, which is also the ?v= that
busts the cache after a change. Incident timeline entries and the queue are
text-only and are unchanged.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
225 lines
9.1 KiB
JavaScript
225 lines
9.1 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, so there is no add or swap
|
||
// button here.
|
||
//
|
||
// 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';
|
||
import { withAvatar } from './avatar.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;
|
||
}
|
||
const team = currentTeam();
|
||
clear(view(),
|
||
// The top bar carries the title on a phone; the desktop has none.
|
||
h('div', { class: 'only-desktop oncall-title' },
|
||
h('h1', { text: 'On-call' }),
|
||
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
|
||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||
h('div', { class: 'oncall-grid' },
|
||
h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
|
||
h('div', { class: 'oncall-side' }, 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 hero' },
|
||
h('div', { class: 'hero-label', text: 'On call now' }),
|
||
h('div', { class: 'hero-top' },
|
||
h('div', { class: 'avatar hero-avatar none', text: '–' }),
|
||
h('div', { class: 'hero-name', text: 'Nobody' })));
|
||
}
|
||
return h('div', { class: 'hero-list' }, ...entries.map((n) => {
|
||
const until = shiftEnd(n);
|
||
return h('div', { class: 'card hero' },
|
||
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||
h('div', { class: 'hero-top' },
|
||
withAvatar(h('div', { class: 'avatar hero-avatar', text: initial(n.username) }), n.user_id),
|
||
h('div', {},
|
||
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||
until && h('div', { class: 'hero-until', text: until }))));
|
||
}));
|
||
}
|
||
|
||
// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
|
||
// end is read off the rota (the run of consecutive days from today held by the
|
||
// same person), and that is only loaded for one team. The same midnight
|
||
// boundary the "Current shift" row below uses.
|
||
function shiftEnd(n) {
|
||
const team = currentTeam();
|
||
if (!team || n.team_id !== team.id) return null;
|
||
const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
|
||
let day = new Date();
|
||
if (mine.get(isoDate(day)) !== n.user_id) return null;
|
||
while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
|
||
// Still holding the last day loaded: the shift may run on past it.
|
||
if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
|
||
const end = addDays(parse(isoDate(day)), 1);
|
||
return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
|
||
}
|
||
|
||
// A stable colour per person from the six-colour rcN palette. The week page
|
||
// has no member list to take an index from (team.js does), so the id decides.
|
||
const personClass = (userID) => `rc${(userID % 6) + 1}`;
|
||
|
||
// The week as seven cells, Monday to Sunday, each showing who holds that day.
|
||
// A hand-over in the middle of the week is visible without reading anything.
|
||
function weekCard() {
|
||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||
const today = isoDate(new Date());
|
||
const mine = myID();
|
||
const seen = new Map();
|
||
const cells = [];
|
||
for (let i = 0; i < 7; i++) {
|
||
const d = addDays(weekStart, i);
|
||
const date = isoDate(d);
|
||
const e = byDate.get(date) || null;
|
||
if (e) seen.set(e.user_id, e.username);
|
||
cells.push(h('li', {
|
||
class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
|
||
'aria-current': date === today ? 'date' : null,
|
||
},
|
||
h('span', { class: 'strip-name', text: dayName.format(d) }),
|
||
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
||
(el => (e ? withAvatar(el, e.user_id) : el))(h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' })),
|
||
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
|
||
}
|
||
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||
return h('div', { class: 'card week-card' },
|
||
h('div', { class: 'week-head' },
|
||
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
||
h('div', { class: 'week-nav' },
|
||
h('button', { class: 'btn btn-icon week-arrow', 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-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||
icon('chevronRight')))),
|
||
h('ul', { class: 'strip' }, cells),
|
||
seen.size > 0 && h('div', { class: 'strip-legend' },
|
||
[...seen].map(([id, name]) => h('span', {},
|
||
h('i', { class: `strip-dot ${personClass(id)}` }),
|
||
id === mine ? `${name} (you)` : name))));
|
||
}
|
||
|
||
// 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`;
|
||
}
|