diff options
| author | Alex Pooley (@zuedev) <zuedev@gmail.com> | 2026-07-16 17:13:58 +0100 |
|---|---|---|
| committer | Alex Pooley (@zuedev) <zuedev@gmail.com> | 2026-07-16 17:13:58 +0100 |
| commit | efa927adf3850cb8f3f38cea9ed4dd01425ade59 (patch) | |
| tree | 9e23eab6353b23ee33c72faf11806aedbf49565c | |
| parent | 1768191b23e2ab224cb584f3301da66db75acdc9 (diff) | |
| download | unnamed-group-efa927adf3850cb8f3f38cea9ed4dd01425ade59.tar unnamed-group-efa927adf3850cb8f3f38cea9ed4dd01425ade59.tar.gz unnamed-group-efa927adf3850cb8f3f38cea9ed4dd01425ade59.tar.bz2 unnamed-group-efa927adf3850cb8f3f38cea9ed4dd01425ade59.tar.xz unnamed-group-efa927adf3850cb8f3f38cea9ed4dd01425ade59.zip | |
on call schedule
| -rw-r--r-- | 174bg/manager/package.json | 3 | ||||
| -rw-r--r-- | 174bg/manager/public/index.html | 464 | ||||
| -rw-r--r-- | 174bg/manager/public/theme.css | 35 |
3 files changed, 496 insertions, 6 deletions
diff --git a/174bg/manager/package.json b/174bg/manager/package.json index 03da670..076395b 100644 --- a/174bg/manager/package.json +++ b/174bg/manager/package.json @@ -3,6 +3,7 @@ "wrangler": "^4.95.0" }, "scripts": { - "deploy": "npx wrangler deploy --minify" + "deploy": "npx wrangler deploy --minify", + "dev": "npx wrangler dev" } } diff --git a/174bg/manager/public/index.html b/174bg/manager/public/index.html index 45b1541..002417a 100644 --- a/174bg/manager/public/index.html +++ b/174bg/manager/public/index.html @@ -50,6 +50,7 @@ </div> </div> <div id="preferences"></div> + <div id="oncall"></div> <div id="ledger"></div> <div> <button id="logout">Logout</button> @@ -445,6 +446,394 @@ ); } + let oncallEditing = false; + let oncallTbody; + let oncallTzContainer; + let oncallEditBtn; + let oncallCancelBtn; + let oncallStatus; + + const DAYS_OF_WEEK = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]; + + // Sunday-first, matching Date#getDay()/getUTCDay() and Intl's "weekday" + const WEEKDAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + const WEEKDAY_INDEX = Object.fromEntries( + WEEKDAY_NAMES.map((d, i) => [d, i]), + ); + + function pad2(n) { + return String(n).padStart(2, "0"); + } + + function getBrowserTimeZone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } + } + + function listTimeZones() { + if (typeof Intl.supportedValuesOf === "function") { + try { + return Intl.supportedValuesOf("timeZone"); + } catch { + // fall through to the fallback list below + } + } + return [ + "UTC", + "America/Los_Angeles", + "America/Denver", + "America/Chicago", + "America/New_York", + "America/Sao_Paulo", + "Europe/London", + "Europe/Paris", + "Europe/Berlin", + "Europe/Moscow", + "Africa/Johannesburg", + "Asia/Dubai", + "Asia/Kolkata", + "Asia/Shanghai", + "Asia/Tokyo", + "Australia/Sydney", + "Pacific/Auckland", + ]; + } + + // Offset (ms) of `timeZone` from UTC at the instant `date` represents. + function tzOffsetMs(date, timeZone) { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const parts = {}; + for (const { type, value } of dtf.formatToParts(date)) { + parts[type] = value; + } + const asUTC = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + parts.hour === "24" ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second), + ); + return asUTC - date.getTime(); + } + + // Formats `timeZone`'s UTC offset at `date` as "UTC+HH:MM"/"UTC-HH:MM". + function formatUtcOffset(timeZone, date = new Date()) { + const totalMinutes = Math.round(tzOffsetMs(date, timeZone) / 60000); + const sign = totalMinutes < 0 ? "-" : "+"; + const abs = Math.abs(totalMinutes); + return `UTC${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`; + } + + // Nearest same-or-later calendar date whose local weekday in `timeZone` + // is `day` - used as a DST-correct anchor for the conversion below. + function nextLocalDateFor(day, timeZone) { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + weekday: "long", + }); + const parts = {}; + for (const { type, value } of dtf.formatToParts(new Date())) { + parts[type] = value; + } + const diff = (WEEKDAY_INDEX[day] - WEEKDAY_INDEX[parts.weekday] + 7) % 7; + const base = new Date( + Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + ), + ); + base.setUTCDate(base.getUTCDate() + diff); + return base; + } + + // Nearest same-or-later UTC calendar date whose UTC weekday is `day`. + function nextUtcDateFor(day) { + const now = new Date(); + const diff = (WEEKDAY_INDEX[day] - now.getUTCDay() + 7) % 7; + const base = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + base.setUTCDate(base.getUTCDate() + diff); + return base; + } + + // Converts a weekday + "HH:MM" wall-clock time in `timeZone` to the + // equivalent UTC weekday + "HH:MM" (the day may shift due to the offset). + function localDayTimeToUtc(day, time, timeZone) { + const [hour, minute] = time.split(":").map(Number); + const base = nextLocalDateFor(day, timeZone); + const guess = Date.UTC( + base.getUTCFullYear(), + base.getUTCMonth(), + base.getUTCDate(), + hour, + minute, + ); + const offset = tzOffsetMs(new Date(guess), timeZone); + const actual = new Date(guess - offset); + return { + day: WEEKDAY_NAMES[actual.getUTCDay()], + time: `${pad2(actual.getUTCHours())}:${pad2(actual.getUTCMinutes())}`, + }; + } + + // Converts a UTC weekday + "HH:MM" back to weekday + "HH:MM" wall-clock + // time in `timeZone`. + function utcDayTimeToLocal(day, time, timeZone) { + const [hour, minute] = time.split(":").map(Number); + const base = nextUtcDateFor(day); + const instant = new Date( + Date.UTC( + base.getUTCFullYear(), + base.getUTCMonth(), + base.getUTCDate(), + hour, + minute, + ), + ); + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + weekday: "long", + hour: "2-digit", + minute: "2-digit", + }); + const parts = {}; + for (const { type, value } of dtf.formatToParts(instant)) { + parts[type] = value; + } + return { + day: parts.weekday, + time: `${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}`, + }; + } + + // onCallSchedule is stored as JSON, UTC-normalized: + // { timezone: "IANA/Zone", [utcDay]: { available, start, end } } + function getOnCallRaw() { + const raw = pb.authStore.record?.onCallSchedule; + if (!raw) return {}; + if (typeof raw === "string") { + try { + return JSON.parse(raw) || {}; + } catch { + return {}; + } + } + return { ...raw }; + } + + function getEffectiveTimezone(raw) { + return raw.timezone || getBrowserTimeZone(); + } + + // Converts the stored UTC schedule to local wall-clock times, keyed by + // local weekday, for display/editing. + function utcScheduleToLocal(raw, timeZone) { + const local = {}; + for (const day of DAYS_OF_WEEK) { + const entry = raw[day]; + if (!entry?.available) continue; + const start = utcDayTimeToLocal(day, entry.start, timeZone); + const end = utcDayTimeToLocal(day, entry.end, timeZone); + local[start.day] = { + available: true, + start: start.time, + end: end.time, + }; + } + return local; + } + + // Converts a local-keyed schedule (from the edit form) to UTC for + // storage. + function localScheduleToUtc(local, timeZone) { + const utc = {}; + for (const day of DAYS_OF_WEEK) { + const entry = local[day]; + if (!entry?.available) continue; + const start = localDayTimeToUtc(day, entry.start || "00:00", timeZone); + const end = localDayTimeToUtc(day, entry.end || "00:00", timeZone); + utc[start.day] = { available: true, start: start.time, end: end.time }; + } + return utc; + } + + // Legacy records saved before timezone support has no `timezone` key - + // those values are already local wall-clock times, so skip conversion. + function getLocalOnCallSchedule() { + const raw = getOnCallRaw(); + const timeZone = getEffectiveTimezone(raw); + const schedule = raw.timezone ? utcScheduleToLocal(raw, timeZone) : raw; + return { timeZone, schedule }; + } + + function renderOnCallTimezone(timeZone, editMode) { + if (editMode) { + const zones = listTimeZones(); + const options = zones.includes(timeZone) ? zones : [timeZone, ...zones]; + const now = new Date(); + const select = document.createElement("select"); + for (const tz of options) { + const option = document.createElement("option"); + option.value = tz; + option.textContent = `${tz} (${formatUtcOffset(tz, now)})`; + option.selected = tz === timeZone; + select.appendChild(option); + } + oncallTzContainer.replaceChildren(select); + } else { + oncallTzContainer.textContent = `${timeZone} (${formatUtcOffset(timeZone)})`; + } + } + + function getSelectedTimezone() { + const select = oncallTzContainer.querySelector("select"); + return select ? select.value : getEffectiveTimezone(getOnCallRaw()); + } + + function renderOnCallSchedule(schedule, editMode) { + oncallTbody.querySelectorAll("tr").forEach((tr) => { + const day = tr.dataset.day; + const entry = schedule[day] ?? {}; + const tds = tr.querySelectorAll("td"); + const availTd = tds[1]; + const startTd = tds[2]; + const endTd = tds[3]; + + if (editMode) { + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = !!entry.available; + availTd.replaceChildren(cb); + + const startInput = document.createElement("input"); + startInput.type = "time"; + startInput.value = entry.start ?? ""; + startInput.disabled = !cb.checked; + + const endInput = document.createElement("input"); + endInput.type = "time"; + endInput.value = entry.end ?? ""; + endInput.disabled = !cb.checked; + + cb.addEventListener("change", () => { + startInput.disabled = !cb.checked; + endInput.disabled = !cb.checked; + }); + + startTd.replaceChildren(startInput); + endTd.replaceChildren(endInput); + } else { + availTd.textContent = entry.available ? "✅" : "❌"; + startTd.textContent = + entry.available && entry.start ? entry.start : "—"; + endTd.textContent = entry.available && entry.end ? entry.end : "—"; + } + }); + } + + function buildOnCallSection() { + const container = document.getElementById("oncall"); + container.innerHTML = ""; + + const heading = document.createElement("div"); + const title = document.createElement("b"); + title.textContent = "On-Call Schedule"; + oncallEditBtn = document.createElement("button"); + oncallEditBtn.type = "button"; + oncallEditBtn.textContent = "✏️ Edit"; + oncallCancelBtn = document.createElement("button"); + oncallCancelBtn.type = "button"; + oncallCancelBtn.textContent = "❌ Cancel"; + oncallCancelBtn.hidden = true; + heading.append(title, " ", oncallEditBtn, " ", oncallCancelBtn); + + const hint = document.createElement("p"); + hint.style.cssText = + "margin:0.25rem 0 0.75rem; font-size:0.85rem; color:var(--text-dim)"; + hint.textContent = + "Let the group know which days and hours you're usually free to join operations."; + + const tzRow = document.createElement("div"); + tzRow.style.cssText = + "display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem"; + const tzLabel = document.createElement("b"); + tzLabel.textContent = "Timezone:"; + oncallTzContainer = document.createElement("span"); + tzRow.append(tzLabel, oncallTzContainer); + + const table = document.createElement("table"); + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + for (const text of ["Day", "Free", "From", "To"]) { + const th = document.createElement("th"); + th.textContent = text; + headerRow.appendChild(th); + } + thead.appendChild(headerRow); + + oncallTbody = document.createElement("tbody"); + for (const day of DAYS_OF_WEEK) { + const tr = document.createElement("tr"); + tr.dataset.day = day; + const tdDay = document.createElement("td"); + tdDay.textContent = day; + const tdAvail = document.createElement("td"); + const tdStart = document.createElement("td"); + const tdEnd = document.createElement("td"); + tr.append(tdDay, tdAvail, tdStart, tdEnd); + oncallTbody.appendChild(tr); + } + + table.append(thead, oncallTbody); + oncallStatus = document.createElement("p"); + oncallStatus.style.cssText = "margin:0.5rem 0 0; font-size:0.85rem"; + container.append(heading, hint, tzRow, table, oncallStatus); + } + + function loadOnCallSchedule() { + oncallEditing = false; + oncallEditBtn.textContent = "✏️ Edit"; + oncallCancelBtn.hidden = true; + const { timeZone, schedule } = getLocalOnCallSchedule(); + renderOnCallTimezone(timeZone, false); + renderOnCallSchedule(schedule, false); + } + function memberLabel(record, id) { const m = record?.expand?.[id]; return m ? m.RSI_Handle || m.name || m.id : (record?.[id] ?? "—"); @@ -541,9 +930,11 @@ function initAuthedUI() { fetchRoles(); buildRolesSection(); + buildOnCallSection(); populateWelcome(); populateRequiredInfo(); loadRoles(); + loadOnCallSchedule(); loadLedger(); } @@ -614,11 +1005,11 @@ } if (!oauthParams.has("code") || !oauthParams.has("state")) { - updateAuthUI(); - if (pb.authStore.isValid) { - initAuthedUI(); - } - } + updateAuthUI(); + if (pb.authStore.isValid) { + initAuthedUI(); + } + } loginBtn.addEventListener("click", async () => { loginBtn.disabled = true; @@ -705,5 +1096,68 @@ false, ); }); + + oncallEditBtn.addEventListener("click", async () => { + if (!oncallEditing) { + oncallEditing = true; + oncallEditBtn.textContent = "💾 Save"; + oncallCancelBtn.hidden = false; + const { timeZone, schedule } = getLocalOnCallSchedule(); + renderOnCallTimezone(timeZone, true); + renderOnCallSchedule(schedule, true); + } else { + const timeZone = getSelectedTimezone(); + const local = {}; + oncallTbody.querySelectorAll("tr").forEach((tr) => { + const day = tr.dataset.day; + const tds = tr.querySelectorAll("td"); + const cb = tds[1].querySelector("input[type=checkbox]"); + const startInput = tds[2].querySelector("input[type=time]"); + const endInput = tds[3].querySelector("input[type=time]"); + if (cb.checked) { + local[day] = { + available: true, + start: startInput.value || "00:00", + end: endInput.value || "00:00", + }; + } + }); + oncallEditBtn.disabled = true; + oncallCancelBtn.disabled = true; + try { + const utcSchedule = localScheduleToUtc(local, timeZone); + const payload = { timezone: timeZone, ...utcSchedule }; + await pb.collection("members").update(pb.authStore.record.id, { + onCallSchedule: payload, + }); + pb.authStore.record.onCallSchedule = payload; + oncallEditing = false; + oncallEditBtn.textContent = "✏️ Edit"; + oncallCancelBtn.hidden = true; + oncallStatus.textContent = ""; + renderOnCallTimezone(timeZone, false); + renderOnCallSchedule(local, false); + } catch (err) { + console.error("Failed to save on-call schedule:", err); + const msg = err?.response + ? JSON.stringify(err.response) + : (err?.message ?? String(err)); + oncallStatus.style.color = "red"; + oncallStatus.textContent = `Save failed: ${msg}`; + } finally { + oncallEditBtn.disabled = false; + oncallCancelBtn.disabled = false; + } + } + }); + + oncallCancelBtn.addEventListener("click", () => { + oncallEditing = false; + oncallEditBtn.textContent = "✏️ Edit"; + oncallCancelBtn.hidden = true; + const { timeZone, schedule } = getLocalOnCallSchedule(); + renderOnCallTimezone(timeZone, false); + renderOnCallSchedule(schedule, false); + }); </script> </html> diff --git a/174bg/manager/public/theme.css b/174bg/manager/public/theme.css index 784f500..907a77e 100644 --- a/174bg/manager/public/theme.css +++ b/174bg/manager/public/theme.css @@ -95,6 +95,7 @@ body > h1, #anonymous, #required-info, #preferences, +#oncall, #ledger, #welcome { background: var(--panel); @@ -162,6 +163,7 @@ button:disabled { /* Tables ----------------------------------------------------------- */ #preferences table, +#oncall table, #ledger table { border-collapse: collapse; width: 100%; @@ -169,6 +171,7 @@ button:disabled { } #preferences thead th, +#oncall thead th, #ledger thead th { text-align: left; padding: 0.5rem 0.75rem; @@ -181,17 +184,20 @@ button:disabled { } #preferences tbody tr:nth-child(even), +#oncall tbody tr:nth-child(even), #ledger tbody tr:nth-child(even) { background: var(--row-alt); } #preferences tbody td, +#oncall tbody td, #ledger tbody td { padding: 0.45rem 0.75rem; border-bottom: 1px solid rgba(56, 189, 248, 0.1); } #preferences tbody tr:hover:not([data-separator]), +#oncall tbody tr:hover, #ledger tbody tr:hover { background: rgba(56, 189, 248, 0.1); } @@ -226,6 +232,33 @@ input[type="checkbox"] { cursor: pointer; } +#oncall input[type="time"] { + font-family: "Rajdhani", sans-serif; + font-size: 0.9rem; + color: var(--text); + background: rgba(15, 23, 42, 0.6); + border: 1px solid var(--panel-border); + border-radius: 6px; + padding: 0.3rem 0.5rem; + color-scheme: dark; +} + +#oncall input[type="time"]:disabled { + opacity: 0.35; +} + +#oncall select { + font-family: "Rajdhani", sans-serif; + font-size: 0.9rem; + color: var(--text); + background: rgba(15, 23, 42, 0.6); + border: 1px solid var(--panel-border); + border-radius: 6px; + padding: 0.3rem 0.5rem; + color-scheme: dark; + max-width: 16rem; +} + /* Status & warnings ------------------------------------------------ */ #oauth-status { font-size: 0.9rem; @@ -266,6 +299,7 @@ input[type="checkbox"] { /* Tables can scroll horizontally if they ever overflow ------------- */ #preferences, +#oncall, #ledger { overflow-x: auto; } @@ -281,6 +315,7 @@ input[type="checkbox"] { #anonymous, #required-info, #preferences, + #oncall, #ledger, #welcome { padding: 1rem 1.1rem; |
