diff --git a/internal/web/static/app.css b/internal/web/static/app.css index ca0fb02..b9f5ea1 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -773,7 +773,7 @@ kbd { 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-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; } .rota-wd { padding-bottom: 4px; text-align: center; color: var(--muted); font-size: 11px; font-weight: 700; @@ -787,6 +787,21 @@ kbd { } button.rota-day { cursor: pointer; } button.rota-day:hover { background: var(--surface-2); } +/* The week number starts each row. Quiet by default, because it is a label + first; an owner's tap on it is the second thing it does. */ +.rota-week { + display: grid; place-items: center; + border: 0; border-radius: var(--radius-sm); background: none; + font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; + color: var(--faint); +} +button.rota-week { cursor: pointer; } +button.rota-week:hover { background: var(--surface-2); color: var(--text); } +.rota-week.current { color: var(--accent); font-weight: 700; } +/* The sheet's row of who holds each day of the week. */ +.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; } +.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; } +.week-holder.past { opacity: 0.55; } .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; } diff --git a/internal/web/static/js/format.js b/internal/web/static/js/format.js index 1cbed84..c50ba1e 100644 --- a/internal/web/static/js/format.js +++ b/internal/web/static/js/format.js @@ -66,6 +66,16 @@ export function mondayOf(d) { return r; } +// ISO 8601 week number: weeks start on Monday and week 1 is the one holding the +// year's first Thursday, which is what a rota that runs Monday to Sunday means +// by "week 40". Taken from the Thursday of d's week, whose year is the week's. +export function isoWeek(d) { + const thu = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + thu.setDate(thu.getDate() + 3 - ((thu.getDay() + 6) % 7)); + const jan4 = new Date(thu.getFullYear(), 0, 4); + return 1 + Math.round(((thu - jan4) / 86400000 - 3 + ((jan4.getDay() + 6) % 7)) / 7); +} + export function addDays(d, n) { const r = new Date(d); r.setDate(r.getDate() + n); diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index 2a2f366..e8bf3aa 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -20,7 +20,7 @@ import * as api from './api.js'; import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.js'; import { state, currentTeam, users as allUsers, myID } from './state.js'; -import { isoDate, addDays, mondayOf, initial, ago, when, duration } from './format.js'; +import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js'; const view = () => document.getElementById('view-team'); @@ -240,6 +240,7 @@ function overview() { const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }); const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' }); +const dayShortFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }); const longDayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'long', day: 'numeric', month: 'long', }); @@ -281,11 +282,14 @@ function scheduleCard() { const key = isoDate(d); const e = byDate.get(key); const inMonth = d.getMonth() === month; + // Every row starts with its week number, which is also the way to fill the + // whole week at once. + if (i % 7 === 0) cells.push(weekCell(d, byDate, today)); 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 = []; + const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })]; 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)) })); @@ -323,6 +327,23 @@ function scheduleCard() { ]; } +// The ISO week number at the start of a row. For an owner it is a button: one +// tap fills the week, which is the way a rota is usually handed out — a person +// takes a week, not seven separate days. +function weekCell(monday, byDate, today) { + const n = isoWeek(monday); + const current = isoDate(monday) <= today && today < isoDate(addDays(monday, 7)); + const cls = `rota-week${current ? ' current' : ''}`; + const label = `Week ${n}`; + return isOwner() + ? h('button', { + class: cls, type: 'button', text: String(n), + title: `${label} · assign somebody for the whole week`, 'aria-label': label, + onclick: () => weekSheet(monday, byDate), + }) + : h('div', { class: cls, title: label, text: String(n) }); +} + function dayCell(d, key, e, inMonth, today) { const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past'] .filter(Boolean).join(' '); @@ -378,6 +399,73 @@ function coverNote(byDate) { ' left this month with nobody on call.'); } +// One week, in the sheet: who holds each day of it, and one person to put on +// all of them. Days already gone are left alone — who was on call last Tuesday +// is a fact, and "the whole week" should not rewrite it — and the week's +// overhang into the next month is included, since it is the same week. +function weekSheet(monday, byDate) { + const today = isoDate(new Date()); + const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i)); + const keys = days.map(isoDate); + const ahead = keys.filter((k) => k >= today); + const range = `${dayShortFmt.format(days[0])} – ${dayShortFmt.format(days[6])}`; + + const who = memberSelect(); + const onlyEmpty = h('input', { type: 'checkbox' }); + const problem = h('p', { class: 'load-error', hidden: true }); + + const holders = h('div', { class: 'week-holders' }, days.map((d, i) => { + const e = byDate.get(keys[i]); + return h('span', { + class: `week-holder${keys[i] < today ? ' past' : ''}`, + title: `${keys[i]} · ${e ? e.username : 'nobody'}`, + }, + h('span', { class: 'rota-num', text: weekdayFmt.format(d) }), + e + ? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) }) + : h('span', { class: 'rota-chip none' })); + })); + + openSheet(() => [ + h('h2', { class: 'sheet-title', text: `Week ${isoWeek(monday)}` }), + h('p', { class: 'sheet-text', text: range }), + holders, + ahead.length + ? [ + h('label', { class: 'sheet-pick' }, 'On call ', who), + h('label', { class: 'checkbox' }, onlyEmpty, ' Only fill days nobody has yet'), + h('p', { class: 'muted small' }, + ahead.length < 7 + ? `Days already past are left alone, so this covers the ${ahead.length} still to come. ` + : '', + 'Anybody already on those days is replaced unless you tick the box.'), + ] + : h('p', { class: 'muted', text: 'This whole week is already over.' }), + problem, + h('div', { class: 'sheet-actions' }, + h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet() }), + ahead.length > 0 && h('button', { + class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign week', + onclick: () => { + const dates = onlyEmpty.checked ? ahead.filter((k) => !byDate.has(k)) : ahead; + if (!who.value) { + problem.textContent = 'There is nobody in this team to assign.'; + problem.hidden = false; + return; + } + if (!dates.length) { + problem.textContent = 'Every day still to come already has somebody.'; + problem.hidden = false; + return; + } + closeSheet(); + act(() => api.assignSchedule(teamID, Number(who.value), dates, !onlyEmpty.checked)); + }, + }), + ), + ]); +} + // 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.