Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36c00acf62 | |||
| 9d1df2b611 |
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# 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
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.27.0
|
version: 0.28.0
|
||||||
appVersion: "v0.27.0"
|
appVersion: "v0.28.0"
|
||||||
|
|||||||
@@ -773,7 +773,7 @@ kbd {
|
|||||||
too alike down a column to read, so a day carries an initial in that
|
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
|
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. */
|
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 {
|
.rota-wd {
|
||||||
padding-bottom: 4px; text-align: center;
|
padding-bottom: 4px; text-align: center;
|
||||||
color: var(--muted); font-size: 11px; font-weight: 700;
|
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||||
@@ -787,6 +787,21 @@ kbd {
|
|||||||
}
|
}
|
||||||
button.rota-day { cursor: pointer; }
|
button.rota-day { cursor: pointer; }
|
||||||
button.rota-day:hover { background: var(--surface-2); }
|
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-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
.rota-day.today { background: var(--accent-soft); }
|
.rota-day.today { background: var(--accent-soft); }
|
||||||
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ export function mondayOf(d) {
|
|||||||
return r;
|
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) {
|
export function addDays(d, n) {
|
||||||
const r = new Date(d);
|
const r = new Date(d);
|
||||||
r.setDate(r.getDate() + n);
|
r.setDate(r.getDate() + n);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.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 { 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');
|
const view = () => document.getElementById('view-team');
|
||||||
|
|
||||||
@@ -240,6 +240,7 @@ function overview() {
|
|||||||
|
|
||||||
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||||||
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
|
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
|
||||||
|
const dayShortFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
|
||||||
const longDayFmt = new Intl.DateTimeFormat(undefined, {
|
const longDayFmt = new Intl.DateTimeFormat(undefined, {
|
||||||
weekday: 'long', day: 'numeric', month: 'long',
|
weekday: 'long', day: 'numeric', month: 'long',
|
||||||
});
|
});
|
||||||
@@ -281,11 +282,14 @@ function scheduleCard() {
|
|||||||
const key = isoDate(d);
|
const key = isoDate(d);
|
||||||
const e = byDate.get(key);
|
const e = byDate.get(key);
|
||||||
const inMonth = d.getMonth() === month;
|
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);
|
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
|
||||||
cells.push(dayCell(d, key, e, inMonth, today));
|
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++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
// Any Monday will do; this one is a Monday.
|
// 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)) }));
|
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) {
|
function dayCell(d, key, e, inMonth, today) {
|
||||||
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
|
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
|
||||||
.filter(Boolean).join(' ');
|
.filter(Boolean).join(' ');
|
||||||
@@ -378,6 +399,73 @@ function coverNote(byDate) {
|
|||||||
' left this month with nobody on call.');
|
' 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
|
// 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
|
// 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.
|
// them, and the day you want to change is the one you just tapped.
|
||||||
|
|||||||
Reference in New Issue
Block a user