aboutsummaryrefslogtreecommitdiff
path: root/174bg/manager/src
diff options
context:
space:
mode:
Diffstat (limited to '174bg/manager/src')
-rw-r--r--174bg/manager/src/App.jsx182
-rw-r--r--174bg/manager/src/components/Ledger.jsx73
-rw-r--r--174bg/manager/src/components/OnCallSchedule.jsx186
-rw-r--r--174bg/manager/src/components/RequiredInfo.jsx66
-rw-r--r--174bg/manager/src/components/RolePreferences.jsx171
-rw-r--r--174bg/manager/src/components/Welcome.jsx14
-rw-r--r--174bg/manager/src/lib/oncallTime.js242
-rw-r--r--174bg/manager/src/lib/otherJsonData.js13
-rw-r--r--174bg/manager/src/lib/pocketbase.js3
-rw-r--r--174bg/manager/src/lib/roles.js142
-rw-r--r--174bg/manager/src/main.jsx10
-rw-r--r--174bg/manager/src/theme.css423
12 files changed, 1525 insertions, 0 deletions
diff --git a/174bg/manager/src/App.jsx b/174bg/manager/src/App.jsx
new file mode 100644
index 0000000..5f455bb
--- /dev/null
+++ b/174bg/manager/src/App.jsx
@@ -0,0 +1,182 @@
+import { useCallback, useEffect, useState } from "react";
+import { pb } from "./lib/pocketbase";
+import Welcome from "./components/Welcome.jsx";
+import RequiredInfo from "./components/RequiredInfo.jsx";
+import RolePreferences from "./components/RolePreferences.jsx";
+import OnCallSchedule from "./components/OnCallSchedule.jsx";
+import Ledger from "./components/Ledger.jsx";
+
+export default function App() {
+ const [record, setRecord] = useState(pb.authStore.record);
+ const [loggedIn, setLoggedIn] = useState(pb.authStore.isValid);
+ const [loginDisabled, setLoginDisabled] = useState(false);
+ const [oauthStatus, setOauthStatus] = useState({ text: "" });
+
+ const refreshFromStore = useCallback(() => {
+ setRecord(pb.authStore.record);
+ setLoggedIn(pb.authStore.isValid);
+ }, []);
+
+ useEffect(() => pb.authStore.onChange(refreshFromStore), [refreshFromStore]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function init() {
+ if (pb.authStore.isValid) {
+ try {
+ await pb.collection("members").authRefresh();
+ } catch {
+ pb.authStore.clear();
+ }
+ }
+
+ const oauthParams = new URLSearchParams(window.location.search);
+ const storedProvider = localStorage.getItem("pb_oauth_provider");
+
+ if (oauthParams.has("code") && oauthParams.has("state")) {
+ if (!storedProvider) {
+ setOauthStatus({
+ text: "Login error: OAuth state lost (localStorage empty). Please try again.",
+ variant: "error-plain",
+ });
+ window.history.replaceState({}, "", window.location.pathname);
+ } else {
+ setOauthStatus({ text: "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,
+ );
+ setOauthStatus({ text: "✅ Login successful!", variant: "success" });
+ window.history.replaceState({}, "", window.location.pathname);
+ if (!cancelled) refreshFromStore();
+ } 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(" | ");
+ setOauthStatus({
+ text: `Login error — send this to your admin:\n${detail}`,
+ variant: "error",
+ });
+ window.history.replaceState({}, "", window.location.pathname);
+ }
+ }
+ } else if (!cancelled) {
+ refreshFromStore();
+ }
+ }
+
+ init();
+ return () => {
+ cancelled = true;
+ };
+ }, [refreshFromStore]);
+
+ const handleLogin = useCallback(async () => {
+ setLoginDisabled(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);
+ setLoginDisabled(false);
+ }
+ }, []);
+
+ const handleLogout = useCallback(() => {
+ pb.authStore.clear();
+ refreshFromStore();
+ }, [refreshFromStore]);
+
+ const handleRecordUpdate = useCallback(
+ (updated) => {
+ pb.authStore.save(pb.authStore.token, updated);
+ refreshFromStore();
+ },
+ [refreshFromStore],
+ );
+
+ let oauthStatusStyle = { fontSize: "0.9rem" };
+ if (oauthStatus.variant === "success") {
+ oauthStatusStyle = {
+ color: "green",
+ background: "#dfd",
+ border: "1px solid #080",
+ borderRadius: "4px",
+ padding: "0.5rem",
+ };
+ } else if (oauthStatus.variant === "error") {
+ oauthStatusStyle = {
+ color: "red",
+ background: "#fdd",
+ border: "1px solid #c00",
+ borderRadius: "4px",
+ padding: "0.5rem",
+ fontFamily: "monospace",
+ fontSize: "0.8rem",
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-all",
+ };
+ } else if (oauthStatus.variant === "error-plain") {
+ oauthStatusStyle = { fontSize: "0.9rem", color: "red" };
+ }
+
+ return (
+ <>
+ <h1>174th Battle Group: Manager</h1>
+
+ {!loggedIn && (
+ <section id="anonymous">
+ <p>You are not logged in. Please log in to access this site:</p>
+ <button id="login" onClick={handleLogin} disabled={loginDisabled}>
+ Login with Discord
+ </button>
+ </section>
+ )}
+
+ {oauthStatus.text && (
+ <p id="oauth-status" style={oauthStatusStyle}>
+ {oauthStatus.text}
+ </p>
+ )}
+
+ {loggedIn && (
+ <section
+ id="authed"
+ style={{ display: "flex", flexDirection: "column", gap: "1em" }}
+ >
+ <Welcome record={record} />
+ <RequiredInfo record={record} />
+ <RolePreferences record={record} onUpdate={handleRecordUpdate} />
+ <OnCallSchedule record={record} onUpdate={handleRecordUpdate} />
+ <Ledger />
+ <div>
+ <button id="logout" onClick={handleLogout}>
+ Logout
+ </button>
+ </div>
+ </section>
+ )}
+ </>
+ );
+}
diff --git a/174bg/manager/src/components/Ledger.jsx b/174bg/manager/src/components/Ledger.jsx
new file mode 100644
index 0000000..247b343
--- /dev/null
+++ b/174bg/manager/src/components/Ledger.jsx
@@ -0,0 +1,73 @@
+import { useEffect, useState } from "react";
+import { pb } from "../lib/pocketbase";
+
+function memberLabel(record, id) {
+ const m = record?.expand?.[id];
+ return m ? m.RSI_Handle || m.name || m.id : (record?.[id] ?? "—");
+}
+
+export default function Ledger() {
+ const [records, setRecords] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ pb
+ .collection("ledger")
+ .getFullList({ expand: "sender,recipient", sort: "-created", requestKey: null })
+ .then((recs) => {
+ if (!cancelled) setRecords(recs);
+ })
+ .catch((err) => {
+ console.error("Failed to load ledger:", err);
+ if (!cancelled) setError(err);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return (
+ <div id="ledger">
+ <div>
+ <b>Ledger</b>
+ </div>
+ {error && (
+ <p className="empty" style={{ color: "red" }}>
+ Failed to load ledger: {error?.message ?? String(error)}
+ </p>
+ )}
+ {!error && records && records.length === 0 && (
+ <p className="empty">No ledger entries.</p>
+ )}
+ {!error && records && records.length > 0 && (
+ <table>
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Sender</th>
+ <th>Recipient</th>
+ <th>UEC</th>
+ <th>Note</th>
+ </tr>
+ </thead>
+ <tbody>
+ {records.map((rec) => (
+ <tr key={rec.id}>
+ <td data-label="Date">
+ {rec.created ? new Date(rec.created).toLocaleString() : "—"}
+ </td>
+ <td data-label="Sender">{memberLabel(rec, "sender")}</td>
+ <td data-label="Recipient">{memberLabel(rec, "recipient")}</td>
+ <td className="uec" data-label="UEC">
+ {Number(rec.uec ?? 0).toLocaleString()}
+ </td>
+ <td data-label="Note">{rec.note ?? ""}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ )}
+ </div>
+ );
+}
diff --git a/174bg/manager/src/components/OnCallSchedule.jsx b/174bg/manager/src/components/OnCallSchedule.jsx
new file mode 100644
index 0000000..725e5c8
--- /dev/null
+++ b/174bg/manager/src/components/OnCallSchedule.jsx
@@ -0,0 +1,186 @@
+import { useMemo, useState } from "react";
+import { pb } from "../lib/pocketbase";
+import {
+ DAYS_OF_WEEK,
+ formatUtcOffset,
+ getLocalOnCallSchedule,
+ listTimeZones,
+ localScheduleToUtc,
+} from "../lib/oncallTime";
+
+export default function OnCallSchedule({ record, onUpdate }) {
+ const [editing, setEditing] = useState(false);
+ const [timeZone, setTimeZone] = useState(null);
+ const [schedule, setSchedule] = useState({});
+ const [saving, setSaving] = useState(false);
+ const [status, setStatus] = useState({ text: "", error: false });
+
+ const display = useMemo(() => getLocalOnCallSchedule(record), [record]);
+ const zones = useMemo(() => listTimeZones(), []);
+
+ function startEdit() {
+ const { timeZone: tz, schedule: sched } = getLocalOnCallSchedule(record);
+ setTimeZone(tz);
+ setSchedule(sched);
+ setEditing(true);
+ setStatus({ text: "", error: false });
+ }
+
+ function cancelEdit() {
+ setEditing(false);
+ }
+
+ function toggleDay(day, available) {
+ setSchedule((prev) => {
+ const next = { ...prev };
+ if (available) {
+ next[day] = {
+ available: true,
+ start: prev[day]?.start ?? "",
+ end: prev[day]?.end ?? "",
+ };
+ } else {
+ delete next[day];
+ }
+ return next;
+ });
+ }
+
+ function setDayTime(day, key, value) {
+ setSchedule((prev) => ({
+ ...prev,
+ [day]: { ...prev[day], [key]: value },
+ }));
+ }
+
+ async function save() {
+ setSaving(true);
+ try {
+ const utcSchedule = localScheduleToUtc(schedule, timeZone);
+ const payload = { timezone: timeZone, ...utcSchedule };
+ const updated = await pb.collection("members").update(record.id, {
+ onCallSchedule: payload,
+ });
+ onUpdate(updated);
+ setEditing(false);
+ setStatus({ text: "", error: false });
+ } catch (err) {
+ const msg = err?.response
+ ? JSON.stringify(err.response)
+ : (err?.message ?? String(err));
+ setStatus({ text: `Save failed: ${msg}`, error: true });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const shownTz = editing ? timeZone : display.timeZone;
+ const shownSchedule = editing ? schedule : display.schedule;
+ const tzOptions = zones.includes(shownTz) ? zones : [shownTz, ...zones];
+
+ return (
+ <div id="oncall">
+ <div>
+ <b>On-Call Schedule</b>{" "}
+ {!editing && (
+ <button type="button" onClick={startEdit}>
+ ✏️ Edit
+ </button>
+ )}
+ {editing && (
+ <>
+ <button type="button" disabled={saving} onClick={save}>
+ 💾 Save
+ </button>{" "}
+ <button type="button" disabled={saving} onClick={cancelEdit}>
+ ❌ Cancel
+ </button>
+ </>
+ )}
+ </div>
+ <p style={{ margin: "0.25rem 0 0.75rem", fontSize: "0.85rem", color: "var(--text-dim)" }}>
+ Let the group know which days and hours you're usually free to join operations.
+ </p>
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem" }}>
+ <b>Timezone:</b>
+ {editing ? (
+ <select value={timeZone} onChange={(e) => setTimeZone(e.target.value)}>
+ {tzOptions.map((tz) => (
+ <option key={tz} value={tz}>
+ {tz} ({formatUtcOffset(tz)})
+ </option>
+ ))}
+ </select>
+ ) : (
+ <span>
+ {shownTz} ({formatUtcOffset(shownTz)})
+ </span>
+ )}
+ </div>
+ <table>
+ <thead>
+ <tr>
+ <th>Day</th>
+ <th>Free</th>
+ <th>From</th>
+ <th>To</th>
+ </tr>
+ </thead>
+ <tbody>
+ {DAYS_OF_WEEK.map((day) => {
+ const entry = shownSchedule[day] ?? {};
+ return (
+ <tr key={day}>
+ <td>{day}</td>
+ <td>
+ {editing ? (
+ <input
+ type="checkbox"
+ checked={!!entry.available}
+ onChange={(e) => toggleDay(day, e.target.checked)}
+ />
+ ) : entry.available ? (
+ "✅"
+ ) : (
+ "❌"
+ )}
+ </td>
+ <td>
+ {editing ? (
+ <input
+ type="time"
+ value={entry.start ?? ""}
+ disabled={!entry.available}
+ onChange={(e) => setDayTime(day, "start", e.target.value)}
+ />
+ ) : entry.available && entry.start ? (
+ entry.start
+ ) : (
+ "—"
+ )}
+ </td>
+ <td>
+ {editing ? (
+ <input
+ type="time"
+ value={entry.end ?? ""}
+ disabled={!entry.available}
+ onChange={(e) => setDayTime(day, "end", e.target.value)}
+ />
+ ) : entry.available && entry.end ? (
+ entry.end
+ ) : (
+ "—"
+ )}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ <p style={{ margin: "0.5rem 0 0", fontSize: "0.85rem", color: status.error ? "red" : "" }}>
+ {status.text}
+ </p>
+ </div>
+ );
+}
diff --git a/174bg/manager/src/components/RequiredInfo.jsx b/174bg/manager/src/components/RequiredInfo.jsx
new file mode 100644
index 0000000..67c6799
--- /dev/null
+++ b/174bg/manager/src/components/RequiredInfo.jsx
@@ -0,0 +1,66 @@
+import { RANK_NAMES } from "../lib/roles";
+
+const REQUIRED_INFO_FIELDS = [
+ { field: "id", label: "174BG ID" },
+ { field: "UEE_Citizen_Record_ID", label: "UEE Citizen Record ID" },
+ { field: "RSI_Display_Name", label: "RSI Display Name" },
+ { field: "RSI_Handle", label: "RSI Handle" },
+ { field: "joined_rsi_org", label: "Joined RSI Organisation" },
+ { field: "email", label: "Email" },
+ { field: "Branch", label: "Branch" },
+ { field: "Rank_Number", label: "Rank Number" },
+];
+
+export default function RequiredInfo({ record }) {
+ let anyMissing = false;
+
+ const rows = REQUIRED_INFO_FIELDS.map(({ field, label }) => {
+ 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.
+ const missing = value == null || value === "";
+ if (missing) anyMissing = true;
+ return { field, label, value, missing };
+ });
+
+ const branch = record?.Branch;
+ const rankNumber = record?.Rank_Number;
+ const rankName = RANK_NAMES[branch]?.[rankNumber];
+ if (!rankName) anyMissing = true;
+
+ const staffRolesRaw = record?.StaffRoles;
+ const staffList = Array.isArray(staffRolesRaw)
+ ? staffRolesRaw
+ : staffRolesRaw
+ ? [staffRolesRaw]
+ : [];
+
+ return (
+ <div id="required-info">
+ <h2>Required Information</h2>
+ {rows.map(({ field, label, value, missing }) => (
+ <div key={field}>
+ <b>{label}:</b>{" "}
+ <span style={{ color: missing ? "red" : "" }}>
+ {missing ? "MISSING" : value}
+ </span>
+ </div>
+ ))}
+ <div>
+ <b>Rank Name:</b>{" "}
+ <span style={{ color: rankName ? "" : "red" }}>
+ {rankName ?? "MISSING"}
+ </span>
+ </div>
+ <div>
+ <b>Staff Roles:</b>{" "}
+ <span>{staffList.length > 0 ? staffList.join(", ") : "none"}</span>
+ </div>
+ {anyMissing && (
+ <div id="profile-warning">
+ ⚠️ Your profile is incomplete. Message an officer ASAP!
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/174bg/manager/src/components/RolePreferences.jsx b/174bg/manager/src/components/RolePreferences.jsx
new file mode 100644
index 0000000..ab3f282
--- /dev/null
+++ b/174bg/manager/src/components/RolePreferences.jsx
@@ -0,0 +1,171 @@
+import { Fragment, useState } from "react";
+import { pb } from "../lib/pocketbase";
+import { ROLES } from "../lib/roles";
+import { getOtherJsonData } from "../lib/otherJsonData";
+
+export default function RolePreferences({ record, onUpdate }) {
+ const [editing, setEditing] = useState(false);
+ const [selected, setSelected] = useState([]);
+ const [favourite, setFavourite] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [status, setStatus] = useState({ text: "", error: false });
+
+ const savedPrefs = record?.RolePreferencesSelect ?? [];
+ const savedFavourite = getOtherJsonData(record).FavouriteRole ?? null;
+
+ function startEdit() {
+ setSelected(savedPrefs);
+ setFavourite(savedFavourite);
+ setEditing(true);
+ setStatus({ text: "", error: false });
+ }
+
+ function cancelEdit() {
+ setEditing(false);
+ }
+
+ function toggleRole(value) {
+ setSelected((prev) =>
+ prev.includes(value)
+ ? prev.filter((v) => v !== value)
+ : [...prev, value],
+ );
+ }
+
+ function toggleFavourite(value) {
+ setFavourite((prev) => (prev === value ? null : value));
+ }
+
+ async function save() {
+ setSaving(true);
+ try {
+ const fav = favourite && selected.includes(favourite) ? favourite : null;
+ const other = getOtherJsonData(record);
+ if (fav) other.FavouriteRole = fav;
+ else delete other.FavouriteRole;
+
+ const updated = await pb.collection("members").update(record.id, {
+ RolePreferencesSelect: selected,
+ OtherJsonData: other,
+ });
+ onUpdate(updated);
+ setEditing(false);
+ setStatus({ text: "", error: false });
+ } catch (err) {
+ const msg = err?.response
+ ? JSON.stringify(err.response)
+ : (err?.message ?? String(err));
+ setStatus({ text: `Save failed: ${msg}`, error: true });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const displayPrefs = editing ? selected : savedPrefs;
+ const displayFavourite = editing ? favourite : savedFavourite;
+
+ let lastBranch = null;
+
+ return (
+ <div id="preferences">
+ <div>
+ <b>Role Preferences</b>{" "}
+ {!editing && (
+ <button type="button" onClick={startEdit}>
+ ✏️ Edit
+ </button>
+ )}
+ {editing && (
+ <>
+ <button type="button" disabled={saving} onClick={save}>
+ 💾 Save
+ </button>{" "}
+ <button type="button" disabled={saving} onClick={cancelEdit}>
+ ❌ Cancel
+ </button>
+ </>
+ )}
+ </div>
+ <table>
+ <thead>
+ <tr>
+ <th>Role</th>
+ <th>✅</th>
+ <th>⭐</th>
+ </tr>
+ </thead>
+ <tbody>
+ {ROLES.map((role) => {
+ const branchHeader = role.branch !== lastBranch;
+ lastBranch = role.branch;
+ return (
+ <Fragment key={role.value}>
+ {branchHeader && (
+ <tr data-separator="true">
+ <td
+ colSpan={3}
+ style={{
+ fontWeight: 600,
+ paddingTop: "0.75rem",
+ paddingBottom: "0.25rem",
+ borderBottom: "none",
+ opacity: 0.6,
+ fontSize: "0.8rem",
+ textTransform: "uppercase",
+ letterSpacing: "0.05em",
+ }}
+ >
+ {role.branch ?? ""}
+ </td>
+ </tr>
+ )}
+ <tr>
+ <td>
+ {role.link ? (
+ <a href={role.link} target="_blank" rel="noopener noreferrer">
+ {role.text}
+ </a>
+ ) : (
+ role.text
+ )}
+ </td>
+ <td>
+ {editing ? (
+ <input
+ type="checkbox"
+ checked={selected.includes(role.value)}
+ onChange={() => toggleRole(role.value)}
+ />
+ ) : displayPrefs.includes(role.value) ? (
+ "✅"
+ ) : (
+ "❌"
+ )}
+ </td>
+ <td>
+ {editing ? (
+ <input
+ type="radio"
+ name="favourite-role"
+ checked={favourite === role.value}
+ onChange={() => {}}
+ onClick={() => toggleFavourite(role.value)}
+ />
+ ) : displayFavourite === role.value ? (
+ "⭐"
+ ) : (
+ ""
+ )}
+ </td>
+ </tr>
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ <p style={{ margin: 0, fontSize: "0.85rem", color: status.error ? "red" : "" }}>
+ {status.text}
+ </p>
+ </div>
+ );
+}
diff --git a/174bg/manager/src/components/Welcome.jsx b/174bg/manager/src/components/Welcome.jsx
new file mode 100644
index 0000000..da771cf
--- /dev/null
+++ b/174bg/manager/src/components/Welcome.jsx
@@ -0,0 +1,14 @@
+export default function Welcome({ record }) {
+ const name = record?.RSI_Handle || record?.name || "Pilot";
+ const avatarFile = record?.avatar;
+ const avatarUrl = avatarFile
+ ? `https://db.174bg.net/api/files/${record.collectionId}/${record.id}/${avatarFile}`
+ : null;
+
+ return (
+ <div id="welcome">
+ {avatarUrl && <img id="welcome-avatar" src={avatarUrl} alt={name} />}
+ <span id="welcome-text">Welcome back, {name}!</span>
+ </div>
+ );
+}
diff --git a/174bg/manager/src/lib/oncallTime.js b/174bg/manager/src/lib/oncallTime.js
new file mode 100644
index 0000000..e8afaf3
--- /dev/null
+++ b/174bg/manager/src/lib/oncallTime.js
@@ -0,0 +1,242 @@
+export const DAYS_OF_WEEK = [
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday",
+];
+
+// Sunday-first, matching Date#getDay()/getUTCDay() and Intl's "weekday"
+export const WEEKDAY_NAMES = [
+ "Sunday",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+];
+export const WEEKDAY_INDEX = Object.fromEntries(
+ WEEKDAY_NAMES.map((d, i) => [d, i]),
+);
+
+export function pad2(n) {
+ return String(n).padStart(2, "0");
+}
+
+export function getBrowserTimeZone() {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+ } catch {
+ return "UTC";
+ }
+}
+
+export 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.
+export 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".
+export 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).
+export 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`.
+export 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}`,
+ };
+}
+
+export function getEffectiveTimezone(raw) {
+ return raw.timezone || getBrowserTimeZone();
+}
+
+// Converts the stored UTC schedule to local wall-clock times, keyed by
+// local weekday, for display/editing.
+export 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.
+export 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;
+}
+
+// onCallSchedule is stored as JSON, UTC-normalized:
+// { timezone: "IANA/Zone", [utcDay]: { available, start, end } }
+export function getOnCallRaw(record) {
+ const raw = record?.onCallSchedule;
+ if (!raw) return {};
+ if (typeof raw === "string") {
+ try {
+ return JSON.parse(raw) || {};
+ } catch {
+ return {};
+ }
+ }
+ return { ...raw };
+}
+
+// Legacy records saved before timezone support has no `timezone` key - those
+// values are already local wall-clock times, so skip conversion.
+export function getLocalOnCallSchedule(record) {
+ const raw = getOnCallRaw(record);
+ const timeZone = getEffectiveTimezone(raw);
+ const schedule = raw.timezone ? utcScheduleToLocal(raw, timeZone) : raw;
+ return { timeZone, schedule };
+}
diff --git a/174bg/manager/src/lib/otherJsonData.js b/174bg/manager/src/lib/otherJsonData.js
new file mode 100644
index 0000000..866e6fc
--- /dev/null
+++ b/174bg/manager/src/lib/otherJsonData.js
@@ -0,0 +1,13 @@
+// OtherJsonData may be stored as an object or a JSON string
+export function getOtherJsonData(record) {
+ const raw = record?.OtherJsonData;
+ if (!raw) return {};
+ if (typeof raw === "string") {
+ try {
+ return JSON.parse(raw) || {};
+ } catch {
+ return {};
+ }
+ }
+ return { ...raw };
+}
diff --git a/174bg/manager/src/lib/pocketbase.js b/174bg/manager/src/lib/pocketbase.js
new file mode 100644
index 0000000..ed0b2eb
--- /dev/null
+++ b/174bg/manager/src/lib/pocketbase.js
@@ -0,0 +1,3 @@
+import PocketBase from "pocketbase";
+
+export const pb = new PocketBase("https://db.174bg.net");
diff --git a/174bg/manager/src/lib/roles.js b/174bg/manager/src/lib/roles.js
new file mode 100644
index 0000000..1d02e37
--- /dev/null
+++ b/174bg/manager/src/lib/roles.js
@@ -0,0 +1,142 @@
+export const 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",
+ },
+];
+
+export const RANK_NAMES = {
+ Naval: ["Cadet", "Ensign", "Lieutenant", "Captain", "Commodore", "Admiral"],
+ Marine: [
+ "Private",
+ "Corporal",
+ "Sergeant",
+ "Major",
+ "Commander",
+ "General",
+ ],
+ Auxiliary: [
+ "Trainee",
+ "Technician",
+ "Specialist",
+ "Supervisor",
+ "Chief",
+ "Marshal",
+ ],
+};
diff --git a/174bg/manager/src/main.jsx b/174bg/manager/src/main.jsx
new file mode 100644
index 0000000..ed1507f
--- /dev/null
+++ b/174bg/manager/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App.jsx";
+import "./theme.css";
+
+createRoot(document.getElementById("root")).render(
+ <StrictMode>
+ <App />
+ </StrictMode>,
+);
diff --git a/174bg/manager/src/theme.css b/174bg/manager/src/theme.css
new file mode 100644
index 0000000..907a77e
--- /dev/null
+++ b/174bg/manager/src/theme.css
@@ -0,0 +1,423 @@
+:root {
+ --bg: #070b14;
+ --bg-grid: rgba(56, 189, 248, 0.06);
+ --panel: rgba(15, 23, 42, 0.72);
+ --panel-border: rgba(56, 189, 248, 0.22);
+ --panel-border-strong: rgba(56, 189, 248, 0.5);
+ --text: #dbe7f3;
+ --text-dim: #7e93ab;
+ --accent: #38bdf8;
+ --accent-2: #22d3ee;
+ --accent-glow: rgba(56, 189, 248, 0.45);
+ --danger: #f87171;
+ --danger-bg: rgba(248, 113, 113, 0.12);
+ --success: #34d399;
+ --row-alt: rgba(56, 189, 248, 0.04);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ font-family: "Rajdhani", system-ui, sans-serif;
+ font-size: 16px;
+ color-scheme: dark;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ padding: 2.5rem 1.25rem 4rem;
+ color: var(--text);
+ background-color: var(--bg);
+ background-image:
+ radial-gradient(
+ ellipse 80% 60% at 50% -10%,
+ rgba(56, 189, 248, 0.18),
+ transparent 60%
+ ),
+ linear-gradient(var(--bg-grid) 1px, transparent 1px),
+ linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px);
+ background-size:
+ 100% 100%,
+ 44px 44px,
+ 44px 44px;
+ background-attachment: fixed;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+h1 {
+ font-family: "Orbitron", sans-serif;
+ font-weight: 900;
+ font-size: clamp(1.5rem, 4vw, 2.4rem);
+ letter-spacing: 0.04em;
+ text-align: center;
+ margin: 0 0 1.75rem;
+ background: linear-gradient(120deg, var(--accent), var(--accent-2));
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ text-shadow: 0 0 28px var(--accent-glow);
+}
+
+h2 {
+ font-family: "Orbitron", sans-serif;
+ font-size: 1.05rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin: 0 0 0.75rem;
+}
+
+a {
+ color: var(--accent-2);
+ text-decoration: none;
+ border-bottom: 1px solid transparent;
+ transition: border-color 0.15s ease;
+}
+
+a:hover {
+ border-bottom-color: var(--accent-2);
+}
+
+body > h1,
+#anonymous,
+#oauth-status,
+#authed {
+ width: 100%;
+ max-width: 760px;
+}
+
+/* Panels ----------------------------------------------------------- */
+#anonymous,
+#required-info,
+#preferences,
+#oncall,
+#ledger,
+#welcome {
+ background: var(--panel);
+ border: 1px solid var(--panel-border);
+ border-radius: 14px;
+ padding: 1.25rem 1.4rem;
+ backdrop-filter: blur(10px);
+ box-shadow:
+ 0 0 0 1px rgba(0, 0, 0, 0.3),
+ 0 18px 48px rgba(0, 0, 0, 0.45);
+}
+
+#anonymous p {
+ margin-top: 0;
+}
+
+/* Buttons ---------------------------------------------------------- */
+button {
+ font-family: "Rajdhani", sans-serif;
+ font-weight: 600;
+ font-size: 0.95rem;
+ letter-spacing: 0.03em;
+ color: #04121f;
+ background: linear-gradient(120deg, var(--accent), var(--accent-2));
+ border: none;
+ border-radius: 8px;
+ padding: 0.55rem 1.1rem;
+ cursor: pointer;
+ transition:
+ transform 0.12s ease,
+ box-shadow 0.2s ease,
+ filter 0.2s ease;
+ box-shadow: 0 6px 18px var(--accent-glow);
+}
+
+button:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 8px 26px var(--accent-glow);
+ filter: brightness(1.08);
+}
+
+button:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+/* Required info ---------------------------------------------------- */
+#required-info > div:not(:first-child):not(#profile-warning) {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.4rem 0;
+ border-bottom: 1px solid rgba(56, 189, 248, 0.08);
+}
+
+#required-info > div b {
+ color: var(--text-dim);
+ font-weight: 600;
+}
+
+/* Tables ----------------------------------------------------------- */
+#preferences table,
+#oncall table,
+#ledger table {
+ border-collapse: collapse;
+ width: 100%;
+ min-width: 16rem;
+}
+
+#preferences thead th,
+#oncall thead th,
+#ledger thead th {
+ text-align: left;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 2px solid var(--panel-border-strong);
+ font-family: "Orbitron", sans-serif;
+ font-size: 0.68rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--accent);
+}
+
+#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);
+}
+
+#preferences thead th:not(:first-child) {
+ text-align: center;
+ width: 2.5rem;
+}
+
+#preferences tbody td:not(:first-child) {
+ text-align: center;
+ width: 2.5rem;
+}
+
+#ledger td.uec {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: var(--accent-2);
+ font-weight: 600;
+}
+
+#ledger .empty {
+ margin: 0.25rem 0 0;
+ font-size: 0.9rem;
+ color: var(--text-dim);
+}
+
+input[type="checkbox"] {
+ width: 1.05rem;
+ height: 1.05rem;
+ accent-color: var(--accent);
+ 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;
+}
+
+#profile-warning {
+ margin-top: 0.75rem;
+ padding: 0.7rem 1rem;
+ background: var(--danger-bg);
+ border: 1px solid var(--danger);
+ border-radius: 8px;
+ color: var(--danger);
+ font-weight: 700;
+}
+
+/* Welcome ---------------------------------------------------------- */
+#welcome {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+#welcome-avatar {
+ width: 3.25rem;
+ height: 3.25rem;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid var(--accent);
+ box-shadow: 0 0 18px var(--accent-glow);
+}
+
+#welcome-text {
+ font-family: "Orbitron", sans-serif;
+ font-size: 1.2rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+/* Tables can scroll horizontally if they ever overflow ------------- */
+#preferences,
+#oncall,
+#ledger {
+ overflow-x: auto;
+}
+
+/* ================================================================= */
+/* Responsive — tablet */
+/* ================================================================= */
+@media (max-width: 640px) {
+ body {
+ padding: 1.5rem 0.85rem 3rem;
+ }
+
+ #anonymous,
+ #required-info,
+ #preferences,
+ #oncall,
+ #ledger,
+ #welcome {
+ padding: 1rem 1.1rem;
+ border-radius: 12px;
+ }
+
+ #welcome {
+ gap: 0.75rem;
+ }
+
+ #welcome-avatar {
+ width: 2.75rem;
+ height: 2.75rem;
+ }
+
+ #welcome-text {
+ font-size: 1.05rem;
+ }
+
+ /* Stack required-info rows so long values don't get squeezed */
+ #required-info > div:not(:first-child):not(#profile-warning) {
+ flex-direction: column;
+ gap: 0.1rem;
+ word-break: break-word;
+ }
+
+ button {
+ width: 100%;
+ }
+
+ #preferences thead th:first-child,
+ #preferences tbody td:first-child {
+ white-space: normal;
+ }
+}
+
+/* ================================================================= */
+/* Responsive — phone: turn the ledger into stacked cards */
+/* ================================================================= */
+@media (max-width: 520px) {
+ #ledger {
+ overflow-x: visible;
+ }
+
+ #ledger table,
+ #ledger thead,
+ #ledger tbody,
+ #ledger tr,
+ #ledger td {
+ display: block;
+ width: 100%;
+ }
+
+ /* Hide the table header row; labels come from data-label instead */
+ #ledger thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ }
+
+ #ledger tbody tr {
+ margin-bottom: 0.85rem;
+ border: 1px solid var(--panel-border);
+ border-radius: 10px;
+ padding: 0.35rem 0.6rem;
+ background: var(--row-alt);
+ }
+
+ #ledger tbody tr:nth-child(even) {
+ background: rgba(56, 189, 248, 0.08);
+ }
+
+ #ledger tbody td {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 1rem;
+ padding: 0.4rem 0.25rem;
+ border-bottom: 1px solid rgba(56, 189, 248, 0.1);
+ text-align: right;
+ word-break: break-word;
+ }
+
+ #ledger tbody tr td:last-child {
+ border-bottom: none;
+ }
+
+ #ledger tbody td::before {
+ content: attr(data-label);
+ font-family: "Orbitron", sans-serif;
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--accent);
+ text-align: left;
+ flex-shrink: 0;
+ }
+
+ #ledger td.uec {
+ text-align: right;
+ }
+}