aboutsummaryrefslogtreecommitdiff
path: root/174bg/manager
diff options
context:
space:
mode:
Diffstat (limited to '174bg/manager')
-rw-r--r--174bg/manager/package.json3
-rw-r--r--174bg/manager/public/index.html464
-rw-r--r--174bg/manager/public/theme.css35
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;