From 9d1df2b6111ee8d8c6b03424ac780035c5a37d26 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Sat, 26 Sep 2026 09:19:35 +0200 Subject: [PATCH] Show week numbers on the rota, and assign a whole week from them The rota grid starts each row with its ISO week number, and for an owner the number is a button: one tap opens a sheet for that week, showing who holds each of its seven days, and puts one person on all of them. A rota is usually handed out by the week, and seven taps on seven days was the only way to do it short of the range form. ISO 8601 numbering, because the grid already runs Monday to Sunday: week 1 is the one holding the year's first Thursday, which is taken from the Thursday of the row so the year boundaries come out right (2025-12-29 is week 1 of 2026, 2020-12-31 is week 53). Days already past are left alone. Who was on call last Tuesday is a fact, and "the whole week" should not rewrite it, so a half-elapsed week covers the days still to come and the sheet says so; a week that is entirely over has nothing to assign. By default the assignment replaces whoever holds those days, as the day sheet does and the sheet states, and a checkbox limits it to the days nobody has yet. The overhang into the neighbouring month is part of the same week and is included. Web UI only: the existing schedule endpoint already takes a list of dates and a replace flag, so nothing changed on the server and there is nothing to mirror in terdut-tui. --- internal/web/static/app.css | 17 +++++- internal/web/static/js/format.js | 10 ++++ internal/web/static/js/team.js | 92 +++++++++++++++++++++++++++++++- 3 files changed, 116 insertions(+), 3 deletions(-) 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.