Compare commits

...

4 Commits

Author SHA1 Message Date
Niklas Ye c5be55dcbc Set the chart's placeholder version to 0.28.1
CI / chart (push) Successful in 2s
CI / security (push) Successful in 15s
CI / test (push) Successful in 2m51s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 31s
Release / image (push) Successful in 59s
Release / scan-image (push) Successful in 3s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 36c00ac and 9bf4c92, so a
tree heading for v0.28.1 doesn't say 0.28.0.
2026-09-26 10:21:54 +02:00
Niklas Ye b2c3868619 Fix the web UI stuck on its loading spinner
format.js defined isoWeek twice. 9d1df2b added a second copy for the
week numbers on the rota without noticing the first, which already
exported the same function. A duplicate function declaration is legal in
a plain script but an early SyntaxError in an ES module, so the browser
refused format.js, every module importing it, and with them app.js. Its
boot() is what hides the spinner, so nothing ever did.

Keeps the first definition, whose comment explains the Thursday rule, and
drops the second. Both give ISO 8601 numbers; the one kept was checked
against 2026-01-01 (week 1), 2026-09-28 (40), 2026-12-31 (53) and
2024-12-30 (1). oncall.js and team.js import the name unchanged.

The suite could not see this: it has no JS, and node --check reads a .js
file as a script, where the redeclaration passes. Checking each file as a
module (.mjs) does catch it.
2026-09-26 10:21:54 +02:00
Niklas Ye 36c00acf62 Set the chart's placeholder version to 0.28.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m1s
Release / test (push) Successful in 5s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 23s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 9bf4c92 and 2b396d2, so a
tree heading for v0.28.0 doesn't say 0.27.0.
2026-09-26 09:19:35 +02:00
Niklas Ye 9d1df2b611 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.
2026-09-26 09:19:35 +02:00
4 changed files with 118 additions and 14 deletions
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.27.0
appVersion: "v0.27.0"
version: 0.28.1
appVersion: "v0.28.1"
+16 -1
View File
@@ -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; }
+10 -9
View File
@@ -66,21 +66,22 @@ 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);
return r;
}
// ISO 8601 week number.
export function isoWeek(d) {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const day = t.getUTCDay() || 7;
t.setUTCDate(t.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
return Math.ceil(((t - yearStart) / DAY + 1) / 7);
}
export const STATUS_LABEL = {
triggered: 'Triggered',
acknowledged: 'Acknowledged',
+90 -2
View File
@@ -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.