aboutsummaryrefslogtreecommitdiff
path: root/174bg/manager/public/index.html
diff options
context:
space:
mode:
Diffstat (limited to '174bg/manager/public/index.html')
-rw-r--r--174bg/manager/public/index.html1163
1 files changed, 0 insertions, 1163 deletions
diff --git a/174bg/manager/public/index.html b/174bg/manager/public/index.html
deleted file mode 100644
index 002417a..0000000
--- a/174bg/manager/public/index.html
+++ /dev/null
@@ -1,1163 +0,0 @@
-<!doctype html>
-<html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <link rel="preconnect" href="https://fonts.googleapis.com" />
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
- <link
- href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@400;500;600;700&display=swap"
- rel="stylesheet"
- />
- <link rel="stylesheet" href="theme.css" />
- </head>
- <body>
- <h1>174th Battle Group: Manager</h1>
-
- <section id="anonymous">
- <p>You are not logged in. Please log in to access this site:</p>
- <button id="login">Login with Discord</button>
- </section>
-
- <p id="oauth-status" style="font-size: 0.9rem"></p>
-
- <section
- id="authed"
- style="display: none; flex-direction: column; gap: 1em"
- >
- <div id="welcome">
- <img id="welcome-avatar" src="" alt="" />
- <span id="welcome-text"></span>
- </div>
- <div id="required-info" style="display: flex; flex-direction: column">
- <h2 style="margin-bottom: 0">Required Information</h2>
- <div><b>174BG ID:</b> <span id="174bg-id"></span></div>
- <div>
- <b>UEE Citizen Record ID:</b> <span id="uee-citizen-record-id"></span>
- </div>
- <div><b>RSI Display Name:</b> <span id="rsi-display-name"></span></div>
- <div><b>RSI Handle:</b> <span id="rsi-handle"></span></div>
- <div>
- <b>Joined RSI Organisation:</b> <span id="joined-rsi-org"></span>
- </div>
- <div><b>Email:</b> <span id="user-email"></span></div>
- <div><b>Branch:</b> <span id="174bg-branch"></span></div>
- <div><b>Rank Number:</b> <span id="174bg-rank-number"></span></div>
- <div><b>Rank Name:</b> <span id="174bg-rank-name"></span></div>
- <div><b>Staff Roles:</b> <span id="174bg-staff-roles"></span></div>
- <div id="profile-warning" hidden>
- ⚠️ Your profile is incomplete. Message an officer ASAP!
- </div>
- </div>
- <div id="preferences"></div>
- <div id="oncall"></div>
- <div id="ledger"></div>
- <div>
- <button id="logout">Logout</button>
- </div>
- </section>
- </body>
- <script type="module">
- import PocketBase from "https://esm.sh/pocketbase@0.27.0";
-
- const pb = new PocketBase("https://db.174bg.net");
-
- function populateWelcome() {
- const record = pb.authStore.record ?? {};
- const avatarEl = document.getElementById("welcome-avatar");
- const textEl = document.getElementById("welcome-text");
-
- const name = record["RSI_Handle"] || record["name"] || "Pilot";
- textEl.textContent = `Welcome back, ${name}!`;
-
- // PocketBase avatar URL pattern
- const avatarFile = record["avatar"];
- if (avatarFile) {
- avatarEl.src = `https://db.174bg.net/api/files/${record.collectionId}/${record.id}/${avatarFile}`;
- avatarEl.alt = name;
- avatarEl.hidden = false;
- } else {
- avatarEl.removeAttribute("src");
- avatarEl.alt = "";
- avatarEl.hidden = true;
- }
- }
-
- // ---------------------------------------------------------------------------
- // Required-info field definitions — map span IDs to PocketBase field names
- // ---------------------------------------------------------------------------
- const REQUIRED_INFO_FIELDS = [
- { id: "174bg-id", field: "id" },
- { id: "uee-citizen-record-id", field: "UEE_Citizen_Record_ID" },
- { id: "rsi-display-name", field: "RSI_Display_Name" },
- { id: "rsi-handle", field: "RSI_Handle" },
- { id: "joined-rsi-org", field: "joined_rsi_org" },
- { id: "user-email", field: "email" },
- { id: "174bg-branch", field: "Branch" },
- { id: "174bg-rank-number", field: "Rank_Number" },
- ];
-
- const RANK_NAMES = {
- Naval: [
- "Cadet",
- "Ensign",
- "Lieutenant",
- "Captain",
- "Commodore",
- "Admiral",
- ],
- Marine: [
- "Private",
- "Corporal",
- "Sergeant",
- "Major",
- "Commander",
- "General",
- ],
- Auxiliary: [
- "Trainee",
- "Technician",
- "Specialist",
- "Supervisor",
- "Chief",
- "Marshal",
- ],
- };
-
- function populateRequiredInfo() {
- const record = pb.authStore.record ?? {};
- let anyMissing = false;
-
- for (const { id, field } of REQUIRED_INFO_FIELDS) {
- const span = document.getElementById(id);
- const value = record[field];
- // Treat only null/undefined/empty-string as missing so legitimately
- // falsy values (e.g. Rank_Number 0, joined_rsi_org false) still show.
- if (value != null && value !== "") {
- span.textContent = value;
- span.style.color = "";
- } else {
- span.textContent = "MISSING";
- span.style.color = "red";
- anyMissing = true;
- }
- }
-
- // Derive rank name from branch + rank number
- const rankSpan = document.getElementById("174bg-rank-name");
- const branch = record["Branch"];
- const rankNumber = record["Rank_Number"];
- const rankName = RANK_NAMES[branch]?.[rankNumber];
- if (rankName) {
- rankSpan.textContent = rankName;
- rankSpan.style.color = "";
- } else {
- rankSpan.textContent = "MISSING";
- rankSpan.style.color = "red";
- anyMissing = true;
- }
-
- // Staff roles — optional list; show "none" when empty (not MISSING)
- const staffSpan = document.getElementById("174bg-staff-roles");
- const staffRoles = record["StaffRoles"];
- const staffList = Array.isArray(staffRoles)
- ? staffRoles
- : staffRoles
- ? [staffRoles]
- : [];
- if (staffList.length > 0) {
- staffSpan.textContent = staffList.join(", ");
- staffSpan.style.color = "";
- } else {
- staffSpan.textContent = "none";
- staffSpan.style.color = "";
- }
-
- if (anyMissing) {
- document.getElementById("profile-warning").hidden = false;
- } else {
- document.getElementById("profile-warning").hidden = true;
- }
- }
-
- let ROLES = [];
-
- function fetchRoles() {
- ROLES = [
- {
- branch: "Naval",
- value: "Ship Captain",
- text: "Ship Captain",
- link: "https://handbook.174bg.net/#ship-captain",
- },
- {
- branch: "Naval",
- value: "Pilot",
- text: "Pilot",
- link: "https://handbook.174bg.net/#pilot",
- },
- {
- branch: "Naval",
- value: "Helmsman",
- text: "Helmsman",
- link: "https://handbook.174bg.net/#helmsman",
- },
- {
- branch: "Naval",
- value: "Gunner",
- text: "Gunner",
- link: "https://handbook.174bg.net/#gunner",
- },
- {
- branch: "Marine",
- value: "Tactical Marine",
- text: "Tactical Marine",
- link: "https://handbook.174bg.net/#tactical-marine",
- },
- {
- branch: "Marine",
- value: "Heavy Marine",
- text: "Heavy Marine",
- link: "https://handbook.174bg.net/#heavy-marine",
- },
- {
- branch: "Marine",
- value: "Scout Marine",
- text: "Scout Marine",
- link: "https://handbook.174bg.net/#scout-marine",
- },
- {
- branch: "Marine",
- value: "Combat Medic",
- text: "Combat Medic",
- link: "https://handbook.174bg.net/#combat-medic",
- },
- {
- branch: "Marine",
- value: "Combat Engineer",
- text: "Combat Engineer",
- link: "https://handbook.174bg.net/#combat-engineer",
- },
- {
- branch: "Marine",
- value: "Warden",
- text: "Warden",
- link: "https://handbook.174bg.net/#warden",
- },
- {
- branch: "Auxiliary",
- value: "Engineer",
- text: "Engineer",
- link: "https://handbook.174bg.net/#engineer",
- },
- {
- branch: "Auxiliary",
- value: "Hazardous Materials Specialist",
- text: "Hazardous Materials Specialist",
- link: "https://handbook.174bg.net/#hazardous-materials-specialist",
- },
- {
- branch: "Auxiliary",
- value: "Medical Doctor",
- text: "Medical Doctor",
- link: "https://handbook.174bg.net/#medical-doctor",
- },
- {
- branch: "Auxiliary",
- value: "Quartermaster",
- text: "Quartermaster",
- link: "https://handbook.174bg.net/#quartermaster",
- },
- {
- branch: "Auxiliary",
- value: "Cargo Technician",
- text: "Cargo Technician",
- link: "https://handbook.174bg.net/#cargo-technician",
- },
- {
- branch: "Auxiliary",
- value: "Craftsman",
- text: "Craftsman",
- link: "https://handbook.174bg.net/#craftsman",
- },
- {
- branch: "Auxiliary",
- value: "Mining Technician",
- text: "Mining Technician",
- link: "https://handbook.174bg.net/#mining-technician",
- },
- {
- branch: "Auxiliary",
- value: "Salvage Operator",
- text: "Salvage Operator",
- link: "https://handbook.174bg.net/#salvage-operator",
- },
- {
- branch: "Auxiliary",
- value: "Expeditionary Analyst",
- text: "Expeditionary Analyst",
- link: "https://handbook.174bg.net/#expeditionary-analyst",
- },
- {
- branch: "Auxiliary",
- value: "Intelligence Agent",
- text: "Intelligence Agent",
- link: "https://handbook.174bg.net/#intelligence-agent",
- },
- ];
- }
-
- let rolesEditing = false;
- let rolesTbody;
- let rolesEditBtn;
- let rolesCancelBtn;
- let rolesStatus;
-
- // OtherJsonData may be stored as an object or a JSON string
- function getOtherJsonData() {
- const raw = pb.authStore.record?.OtherJsonData;
- if (!raw) return {};
- if (typeof raw === "string") {
- try {
- return JSON.parse(raw) || {};
- } catch {
- return {};
- }
- }
- return { ...raw };
- }
-
- function getFavouriteRole() {
- return getOtherJsonData().FavouriteRole ?? null;
- }
-
- function renderRoles(saved, favourite, editMode) {
- rolesTbody.querySelectorAll("tr:not([data-separator])").forEach((tr) => {
- const tds = tr.querySelectorAll("td");
- const prefTd = tds[1];
- const favTd = tds[2];
- const value = tr.dataset.roleValue;
- if (editMode) {
- const cb = document.createElement("input");
- cb.type = "checkbox";
- cb.checked = saved.includes(value);
- prefTd.replaceChildren(cb);
-
- const radio = document.createElement("input");
- radio.type = "radio";
- radio.name = "favourite-role";
- radio.value = value;
- radio.checked = favourite === value;
- radio.dataset.wasChecked = radio.checked ? "true" : "false";
- // Clicking an already-selected radio clears the favourite
- radio.addEventListener("click", () => {
- if (radio.dataset.wasChecked === "true") {
- radio.checked = false;
- radio.dataset.wasChecked = "false";
- } else {
- rolesTbody
- .querySelectorAll("input[name=favourite-role]")
- .forEach((r) => (r.dataset.wasChecked = "false"));
- radio.dataset.wasChecked = "true";
- }
- });
- favTd.replaceChildren(radio);
- } else {
- prefTd.textContent = saved.includes(value) ? "✅" : "❌";
- favTd.textContent = favourite === value ? "⭐" : "";
- }
- });
- }
-
- function buildRolesSection() {
- const container = document.getElementById("preferences");
- container.innerHTML = "";
-
- const heading = document.createElement("div");
- const title = document.createElement("b");
- title.textContent = "Role Preferences";
- rolesEditBtn = document.createElement("button");
- rolesEditBtn.type = "button";
- rolesEditBtn.textContent = "✏️ Edit";
- rolesCancelBtn = document.createElement("button");
- rolesCancelBtn.type = "button";
- rolesCancelBtn.textContent = "❌ Cancel";
- rolesCancelBtn.hidden = true;
- heading.append(title, " ", rolesEditBtn, " ", rolesCancelBtn);
-
- const table = document.createElement("table");
- const thead = document.createElement("thead");
- const headerRow = document.createElement("tr");
- for (const text of ["Role", "✅", "⭐"]) {
- const th = document.createElement("th");
- th.textContent = text;
- headerRow.appendChild(th);
- }
- thead.appendChild(headerRow);
-
- rolesTbody = document.createElement("tbody");
- let lastBranch = null;
- for (const role of ROLES) {
- if (role.branch !== lastBranch) {
- lastBranch = role.branch;
- const sep = document.createElement("tr");
- sep.dataset.separator = "true";
- const tdSep = document.createElement("td");
- tdSep.colSpan = 3;
- tdSep.textContent = role.branch ?? "";
- tdSep.style.cssText =
- "font-weight:600; padding-top:0.75rem; padding-bottom:0.25rem; border-bottom:none; opacity:0.6; font-size:0.8rem; text-transform:uppercase; letter-spacing:0.05em";
- sep.appendChild(tdSep);
- rolesTbody.appendChild(sep);
- }
- const tr = document.createElement("tr");
- tr.dataset.roleValue = role.value;
- const tdName = document.createElement("td");
- if (role.link) {
- const a = document.createElement("a");
- a.href = role.link;
- a.textContent = role.text;
- a.target = "_blank";
- a.rel = "noopener noreferrer";
- tdName.appendChild(a);
- } else {
- tdName.textContent = role.text;
- }
- const tdCheck = document.createElement("td");
- const tdFav = document.createElement("td");
- tr.append(tdName, tdCheck, tdFav);
- rolesTbody.appendChild(tr);
- }
-
- table.append(thead, rolesTbody);
- rolesStatus = document.createElement("p");
- rolesStatus.style.cssText = "margin:0; font-size:0.85rem";
- container.append(heading, table, rolesStatus);
- }
-
- function loadRoles() {
- rolesEditing = false;
- rolesEditBtn.textContent = "✏️ Edit";
- rolesCancelBtn.hidden = true;
- renderRoles(
- pb.authStore.record?.RolePreferencesSelect ?? [],
- getFavouriteRole(),
- false,
- );
- }
-
- 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] ?? "—");
- }
-
- async function loadLedger() {
- const container = document.getElementById("ledger");
- container.innerHTML = "";
-
- const heading = document.createElement("div");
- const title = document.createElement("b");
- title.textContent = "Ledger";
- heading.appendChild(title);
- container.appendChild(heading);
-
- const userId = pb.authStore.record?.id;
- if (!userId) return;
-
- let records;
- try {
- records = await pb.collection("ledger").getFullList({
- expand: "sender,recipient",
- sort: "-created",
- requestKey: null,
- });
- } catch (err) {
- console.error("Failed to load ledger:", err);
- const p = document.createElement("p");
- p.className = "empty";
- p.style.color = "red";
- p.textContent = `Failed to load ledger: ${err?.message ?? String(err)}`;
- container.appendChild(p);
- return;
- }
-
- if (records.length === 0) {
- const p = document.createElement("p");
- p.className = "empty";
- p.textContent = "No ledger entries.";
- container.appendChild(p);
- return;
- }
-
- const table = document.createElement("table");
- const thead = document.createElement("thead");
- const headerRow = document.createElement("tr");
- for (const text of ["Date", "Sender", "Recipient", "UEC", "Note"]) {
- const th = document.createElement("th");
- th.textContent = text;
- headerRow.appendChild(th);
- }
- thead.appendChild(headerRow);
-
- const tbody = document.createElement("tbody");
- for (const rec of records) {
- const tr = document.createElement("tr");
-
- const tdDate = document.createElement("td");
- tdDate.dataset.label = "Date";
- tdDate.textContent = rec.created
- ? new Date(rec.created).toLocaleString()
- : "—";
-
- const tdSender = document.createElement("td");
- tdSender.dataset.label = "Sender";
- tdSender.textContent = memberLabel(rec, "sender");
-
- const tdRecipient = document.createElement("td");
- tdRecipient.dataset.label = "Recipient";
- tdRecipient.textContent = memberLabel(rec, "recipient");
-
- const tdUec = document.createElement("td");
- tdUec.className = "uec";
- tdUec.dataset.label = "UEC";
- tdUec.textContent = Number(rec.uec ?? 0).toLocaleString();
-
- const tdNote = document.createElement("td");
- tdNote.dataset.label = "Note";
- tdNote.textContent = rec.note ?? "";
-
- tr.append(tdDate, tdSender, tdRecipient, tdUec, tdNote);
- tbody.appendChild(tr);
- }
-
- table.append(thead, tbody);
- container.appendChild(table);
- }
-
- const loginBtn = document.getElementById("login");
- const logoutBtn = document.getElementById("logout");
- const anonymousSection = document.getElementById("anonymous");
- const authedSection = document.getElementById("authed");
-
- function initAuthedUI() {
- fetchRoles();
- buildRolesSection();
- buildOnCallSection();
- populateWelcome();
- populateRequiredInfo();
- loadRoles();
- loadOnCallSchedule();
- loadLedger();
- }
-
- function updateAuthUI() {
- const loggedIn = pb.authStore.isValid;
- anonymousSection.style.display = loggedIn ? "none" : "";
- authedSection.style.display = loggedIn ? "flex" : "none";
- }
-
- // Verify the stored session is still valid server-side
- if (pb.authStore.isValid) {
- try {
- await pb.collection("members").authRefresh();
- } catch {
- pb.authStore.clear();
- }
- }
-
- // Complete OAuth redirect if returning from Discord
- const oauthParams = new URLSearchParams(window.location.search);
- const storedProvider = localStorage.getItem("pb_oauth_provider");
- const oauthStatus = document.getElementById("oauth-status");
-
- if (oauthParams.has("code") && oauthParams.has("state")) {
- if (!storedProvider) {
- oauthStatus.textContent =
- "Login error: OAuth state lost (localStorage empty). Please try again.";
- oauthStatus.style.color = "red";
- window.history.replaceState({}, "", window.location.pathname);
- } else {
- oauthStatus.textContent = "Completing login...";
- const provider = JSON.parse(storedProvider);
- localStorage.removeItem("pb_oauth_provider");
- const redirectUrl = window.location.origin + window.location.pathname;
- try {
- await pb
- .collection("members")
- .authWithOAuth2Code(
- provider.name,
- oauthParams.get("code"),
- provider.codeVerifier,
- redirectUrl,
- );
- oauthStatus.style.cssText =
- "color:green; background:#dfd; border:1px solid #080; border-radius:4px; padding:0.5rem";
- oauthStatus.textContent = "✅ Login successful!";
- window.history.replaceState({}, "", window.location.pathname);
- updateAuthUI();
- initAuthedUI();
- } catch (err) {
- console.error("OAuth callback failed:", err);
- const detail = [
- err?.message,
- err?.response ? JSON.stringify(err.response) : null,
- `code=${oauthParams.get("code")?.slice(0, 8)}…`,
- `state=${oauthParams.get("state")?.slice(0, 8)}…`,
- `redirect=${redirectUrl}`,
- `provider=${provider.name}`,
- ]
- .filter(Boolean)
- .join(" | ");
- oauthStatus.style.cssText =
- "color:red; background:#fdd; border:1px solid #c00; border-radius:4px; padding:0.5rem; font-family:monospace; font-size:0.8rem; white-space:pre-wrap; word-break:break-all";
- oauthStatus.textContent = `Login error — send this to your admin:\n${detail}`;
- window.history.replaceState({}, "", window.location.pathname);
- }
- }
- }
-
- if (!oauthParams.has("code") || !oauthParams.has("state")) {
- updateAuthUI();
- if (pb.authStore.isValid) {
- initAuthedUI();
- }
- }
-
- loginBtn.addEventListener("click", async () => {
- loginBtn.disabled = true;
- try {
- const methods = await pb.collection("members").listAuthMethods();
- const provider = methods.oauth2.providers.find(
- (p) => p.name === "discord",
- );
- if (!provider) throw new Error("Discord provider not found");
- const redirectUrl = window.location.origin + window.location.pathname;
- localStorage.setItem("pb_oauth_provider", JSON.stringify(provider));
- window.location.href =
- provider.authUrl + encodeURIComponent(redirectUrl);
- } catch (err) {
- console.error("Login failed:", err);
- loginBtn.disabled = false;
- }
- });
-
- logoutBtn.addEventListener("click", () => {
- pb.authStore.clear();
- updateAuthUI();
- });
-
- rolesEditBtn.addEventListener("click", async () => {
- if (!rolesEditing) {
- rolesEditing = true;
- rolesEditBtn.textContent = "💾 Save";
- rolesCancelBtn.hidden = false;
- renderRoles(
- pb.authStore.record?.RolePreferencesSelect ?? [],
- getFavouriteRole(),
- true,
- );
- } else {
- const selected = [
- ...rolesTbody.querySelectorAll("input[type=checkbox]:checked"),
- ].map((cb) => cb.closest("tr").dataset.roleValue);
- const favRadio = rolesTbody.querySelector(
- "input[name=favourite-role]:checked",
- );
- // Favourite must be one of the selected preferences
- let favourite = favRadio ? favRadio.value : null;
- if (favourite && !selected.includes(favourite)) favourite = null;
- rolesEditBtn.disabled = true;
- rolesCancelBtn.disabled = true;
- try {
- const prefs = selected;
- const other = getOtherJsonData();
- if (favourite) other.FavouriteRole = favourite;
- else delete other.FavouriteRole;
- await pb.collection("members").update(pb.authStore.record.id, {
- RolePreferencesSelect: prefs,
- OtherJsonData: other,
- });
- pb.authStore.record.RolePreferencesSelect = prefs;
- pb.authStore.record.OtherJsonData = other;
- rolesEditing = false;
- rolesEditBtn.textContent = "✏️ Edit";
- rolesCancelBtn.hidden = true;
- rolesStatus.textContent = "";
- renderRoles(selected, favourite, false);
- } catch (err) {
- console.error("Failed to save role preferences:", err);
- const msg = err?.response
- ? JSON.stringify(err.response)
- : (err?.message ?? String(err));
- rolesStatus.style.color = "red";
- rolesStatus.textContent = `Save failed: ${msg}`;
- } finally {
- rolesEditBtn.disabled = false;
- rolesCancelBtn.disabled = false;
- }
- }
- });
-
- rolesCancelBtn.addEventListener("click", () => {
- rolesEditing = false;
- rolesEditBtn.textContent = "✏️ Edit";
- rolesCancelBtn.hidden = true;
- renderRoles(
- pb.authStore.record?.RolePreferencesSelect ?? [],
- getFavouriteRole(),
- 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>