const state = { data: null, view: "mine", propertyId: "class", drafts: {}, sort: { key: "displayName", direction: 1 }, groupMode: "rank", search: "", busy: false, notice: "", }; const app = document.querySelector("#app"); function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[character]); } async function api(path, options = {}) { const response = await fetch(path, { credentials: "same-origin", ...options, headers: { ...(options.body ? { "Content-Type": "application/json", "X-Requested-With": "PreferencesSPA" } : {}), ...(options.headers || {}), }, }); const payload = response.status === 204 ? null : await response.json(); if (!response.ok) { const error = new Error(payload?.error || "Something went wrong. Please try again."); error.status = response.status; error.suggestions = payload?.suggestions || []; throw error; } return payload; } async function load(preserveDrafts = false) { const data = await api("/api/bootstrap"); state.data = data; if (!preserveDrafts) { state.drafts = {}; for (const property of data.properties) { state.drafts[property.id] = property.id === "content" ? { ...(data.preferences?.[property.id]?.choices || {}) } : [...(data.preferences?.[property.id]?.ranking || property.categories)]; } } render(); } function property() { return state.data.properties.find((item) => item.id === state.propertyId); } function propertyTabs() { return `
${state.data.properties.map((item) => ``).join("")}
`; } function shell(content) { const username = state.data.user.displayName; app.innerHTML = `
Bravo TeamForever Interest

Share your preferences

Rank your class and role choices, and tell the group which content you would play. You can update your choices any time.

${state.notice ? `
${escapeHtml(state.notice)}
` : ""} ${content}
`; app.querySelector("#logout").addEventListener("click", logout); app.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => { state.view = button.dataset.view; state.notice = ""; render(); })); app.querySelectorAll("[data-property]").forEach((button) => button.addEventListener("click", () => { state.propertyId = button.dataset.property; state.sort = { key: "displayName", direction: 1 }; state.notice = ""; render(); })); } function renderLogin(error = "", success = "") { app.innerHTML = `

Bravo Team
Forever Interest

${success ? `
${escapeHtml(success)}
` : ""}
Bravo Team · Forever Interest
`; const form = app.querySelector("#login-form"); const nameField = form.elements.namedItem("username"); const passwordField = form.elements.namedItem("password"); const suggestionBox = form.querySelector("#username-suggestions"); let requestId = 0; let timer; let createNewName = null; function showSuggestions(names, confirmNewName = null) { if (!names.length) { suggestionBox.innerHTML = ""; return; } suggestionBox.innerHTML = `

Existing usernames — is one of these yours?

${names.map((name) => ``).join("")}
${confirmNewName ? `` : ""}`; suggestionBox.querySelectorAll("[data-suggested-name]").forEach((button) => button.addEventListener("click", () => { requestId += 1; clearTimeout(timer); nameField.value = button.dataset.suggestedName; createNewName = null; suggestionBox.innerHTML = ""; form.querySelector(".form-error").textContent = ""; nameField.focus(); })); suggestionBox.querySelector(".create-anyway")?.addEventListener("click", () => { createNewName = nameField.value.trim(); form.requestSubmit(); }); } function updateSuggestions() { createNewName = null; requestId += 1; clearTimeout(timer); suggestionBox.innerHTML = ""; const username = nameField.value.trim(); if (username.length < 2) return; const currentRequest = requestId; timer = setTimeout(async () => { try { const result = await api("/api/username-suggestions", { method: "POST", body: JSON.stringify({ loginUsername: username }), }); if (currentRequest === requestId) showSuggestions(result.suggestions); } catch { if (currentRequest === requestId) suggestionBox.innerHTML = ""; } }, 500); } nameField.addEventListener("input", updateSuggestions); form.addEventListener("submit", async (event) => { event.preventDefault(); const button = form.querySelector('button[type="submit"]'); button.disabled = true; clearTimeout(timer); requestId += 1; const username = nameField.value.trim(); try { await api("/api/login", { method: "POST", body: JSON.stringify({ loginUsername: username, password: passwordField.value, createNew: createNewName === username, }), }); await load(); } catch (error) { form.querySelector(".form-error").textContent = error.message; if (error.status === 409) showSuggestions(error.suggestions, username); button.disabled = false; } }); } function renderEditor() { const item = property(); if (item.id === "content") { renderContentEditor(); return; } const draft = state.drafts[item.id]; const saved = state.data.preferences?.[item.id]?.ranking; const excluded = item.categories.filter((category) => !draft.includes(category)); const changed = !!saved && (draft.length !== saved.length || draft.some((value, index) => value !== saved[index])); const status = !saved ? "Not submitted" : changed ? "Unsaved changes" : "Saved"; const cards = draft.map((category, index) => `
  • ${String(index + 1).padStart(2, "0")} ${escapeHtml(category)}
  • `).join(""); const excludedCards = excluded.map((category) => `
  • ${escapeHtml(category)}NO PREFERENCE
  • `).join(""); shell(`
    01 / YOUR RANKING${status}

    Rank your ${escapeHtml(item.name.toLowerCase())} choices

    Put your favorites at the top. Select × for any choice you do not want to rank; you can restore it below.

    ${draft.length} RANKED
    ${propertyTabs()}
    MOST PREFERREDDRAG TO ORDER
      ${cards}
    ${draft.length ? `
    LOWEST RANKED
    ` : `

    No ranked choices. Restore a category below to rank it.

    `}
    No preference${excluded.length} EXCLUDED

    Excluded choices have no rank. They appear blank in group tables and sort as the lowest preference.

    ${excluded.length ? `
      ${excludedCards}
    ` : `

    No categories excluded.

    `}
    `); app.querySelectorAll("[data-move]").forEach((button) => button.addEventListener("click", () => move(Number(button.dataset.index), button.dataset.move === "up" ? -1 : 1))); app.querySelectorAll("[data-exclude]").forEach((button) => button.addEventListener("click", () => { state.drafts[item.id] = draft.filter((category) => category !== button.dataset.exclude); state.notice = ""; render(); })); app.querySelectorAll("[data-restore]").forEach((button) => button.addEventListener("click", () => { state.drafts[item.id].push(button.dataset.restore); state.notice = ""; render(); })); app.querySelectorAll(".drag-handle").forEach((handle) => { handle.addEventListener("pointerdown", startDrag); handle.addEventListener("keydown", (event) => { if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault(); move(Number(handle.dataset.index), event.key === "ArrowUp" ? -1 : 1, true); } }); }); app.querySelector("#save").addEventListener("click", savePreference); } function renderContentEditor() { const item = property(); const draft = state.drafts.content; const saved = state.data.preferences?.content?.choices; const answered = item.categories.filter((category) => ["Yes", "Maybe", "No"].includes(draft[category])).length; const changed = !!saved && item.categories.some((category) => draft[category] !== saved[category]); const status = !saved ? "Not submitted" : changed ? "Unsaved changes" : "Saved"; const rows = item.categories.map((category) => `
    ${escapeHtml(category)}
    ${["Yes", "Maybe", "No"].map((choice) => ``).join("")}
    `).join(""); shell(`
    01 / YOUR CONTENT${status}

    Choose your content interests

    Select Yes, Maybe, or No for each activity.

    ${answered} / ${item.categories.length} ANSWERED
    ${propertyTabs()}
    ${rows}
    `); app.querySelectorAll("[data-content-category]").forEach((button) => button.addEventListener("click", () => { state.drafts.content[button.dataset.contentCategory] = button.dataset.contentChoice; state.notice = ""; render(); })); app.querySelector("#save").addEventListener("click", savePreference); } function move(index, delta, refocus = false) { const list = state.drafts[state.propertyId]; const next = index + delta; if (next < 0 || next >= list.length) return; [list[index], list[next]] = [list[next], list[index]]; state.notice = ""; render(); if (refocus) app.querySelector(`.drag-handle[data-index="${next}"]`)?.focus(); } function startDrag(event) { if (event.button !== 0) return; event.preventDefault(); const handle = event.currentTarget; const row = handle.closest(".rank-card"); const list = app.querySelector("#rank-list"); const from = Number(handle.dataset.index); const startY = event.clientY; row.classList.add("dragging"); handle.setPointerCapture(event.pointerId); const onMove = (moveEvent) => { const delta = moveEvent.clientY - startY; row.style.transform = `translateY(${delta}px)`; }; const finish = (endEvent) => { handle.removeEventListener("pointermove", onMove); handle.removeEventListener("pointerup", finish); handle.removeEventListener("pointercancel", cancel); const others = [...list.children].filter((child) => child !== row); let to = others.findIndex((child) => endEvent.clientY < child.getBoundingClientRect().top + child.getBoundingClientRect().height / 2); if (to < 0) to = others.length; row.style.transform = ""; row.classList.remove("dragging"); if (to !== from) { const draft = state.drafts[state.propertyId]; const [value] = draft.splice(from, 1); draft.splice(to, 0, value); state.notice = ""; render(); } }; const cancel = () => { handle.removeEventListener("pointermove", onMove); handle.removeEventListener("pointerup", finish); handle.removeEventListener("pointercancel", cancel); row.style.transform = ""; row.classList.remove("dragging"); }; handle.addEventListener("pointermove", onMove); handle.addEventListener("pointerup", finish); handle.addEventListener("pointercancel", cancel); } async function savePreference() { if (state.busy) return; const propertyId = state.propertyId; const item = property(); if (propertyId === "content" && item.categories.some((category) => !["Yes", "Maybe", "No"].includes(state.drafts.content[category]))) return; state.busy = true; render(); try { await api(`/api/preferences/${propertyId}`, { method: "PUT", body: JSON.stringify(propertyId === "content" ? { choices: state.drafts.content } : { ranking: state.drafts[propertyId] }), }); await load(true); state.notice = `${item.name} preferences saved.`; } catch (error) { state.notice = error.message; } finally { state.busy = false; render(); } } function formatDate(value) { if (!value) return "—"; return new Date(value).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" }); } function renderAccount() { const { loginUsername, displayName } = state.data.user; shell(`
    03 / YOUR ACCOUNT

    Account settings

    Change the name you sign in with or the name shown to the group.

    DELETE ACCOUNT

    Remove yourself from the roster

    This permanently deletes your account, all saved preferences, and all active sessions. Type ${escapeHtml(displayName)} exactly to confirm.

    `); const accountForm = app.querySelector("#account-form"); accountForm.addEventListener("submit", async (event) => { event.preventDefault(); const button = accountForm.querySelector('button[type="submit"]'); button.disabled = true; accountForm.querySelector(".form-error").textContent = ""; try { await api("/api/me", { method: "PUT", body: JSON.stringify({ loginUsername: accountForm.elements.namedItem("loginUsername").value, displayName: accountForm.elements.namedItem("displayName").value, }), }); await load(true); state.notice = "Account changes saved."; render(); } catch (error) { accountForm.querySelector(".form-error").textContent = error.message; button.disabled = false; } }); const form = app.querySelector("#delete-form"); const input = form.querySelector("#confirm-display-name"); const button = form.querySelector('button[type="submit"]'); input.addEventListener("input", () => { button.disabled = input.value !== displayName; form.querySelector("#delete-error").textContent = ""; }); form.addEventListener("submit", async (event) => { event.preventDefault(); if (input.value !== displayName) return; button.disabled = true; try { await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmDisplayName: input.value }) }); state.data = null; state.drafts = {}; state.view = "mine"; state.notice = ""; renderLogin("", "Your account and preferences were deleted."); } catch (error) { form.querySelector("#delete-error").textContent = error.message; button.disabled = false; } }); } function ordinal(number) { const suffix = number % 100 >= 11 && number % 100 <= 13 ? "th" : ({ 1: "st", 2: "nd", 3: "rd" }[number % 10] || "th"); return `${number}${suffix}`; } function sortPeople(people, propertyId) { const { key, direction } = state.sort; return [...people].sort((a, b) => { const value = (person) => { if (key === "displayName") return person.displayName; const preference = person.preferences[propertyId]; if (key === "updated") return preference?.updatedAt; if (key.startsWith("category:")) { if (propertyId === "content") { return { Yes: 1, Maybe: 2, No: 3 }[preference?.choices[key.slice(9)]] ?? null; } if (!preference) return null; const index = preference?.ranking.indexOf(key.slice(9)); return index < 0 ? Number.MAX_SAFE_INTEGER : index + 1; } return preference?.ranking[Number(key.slice(4))]; }; const aValue = value(a); const bValue = value(b); if (aValue == null && bValue != null) return 1; if (bValue == null && aValue != null) return -1; if (aValue == null && bValue == null && !!a.preferences[propertyId] !== !!b.preferences[propertyId]) { return a.preferences[propertyId] ? -1 : 1; } const comparison = typeof aValue === "number" && typeof bValue === "number" ? aValue - bValue : String(aValue || "").localeCompare(String(bValue || ""), undefined, { sensitivity: "base" }); return comparison * direction || a.loginUsername.localeCompare(b.loginUsername, undefined, { sensitivity: "base" }) || a.userId - b.userId; }); } function sortHeader(key, label) { const active = state.sort.key === key; return ``; } function renderGroup() { const item = property(); const isContent = item.id === "content"; const query = state.search.trim().toLocaleLowerCase(); const people = sortPeople(state.data.people.filter((person) => person.displayName.toLocaleLowerCase().includes(query) || person.loginUsername.toLocaleLowerCase().includes(query) ), item.id); const submitted = state.data.people.filter((person) => person.preferences[item.id]).length; const columns = !isContent && state.groupMode === "rank" ? item.categories.map((_, index) => ({ key: `rank${index}`, label: ordinal(index + 1), index })) : item.categories.map((category) => ({ key: `category:${category}`, label: category, category })); const headers = columns.map((column) => `${sortHeader(column.key, escapeHtml(column.label))}`).join(""); const rows = people.map((person) => { const preference = person.preferences[item.id]; const ranks = columns.map((column) => { if (!preference) return `—`; if (isContent) { const choice = preference.choices[column.category]; return `${choice}`; } const rank = column.category ? preference.ranking.indexOf(column.category) + 1 : column.index + 1; if (column.category && rank === 0) return ``; if (!column.category && !preference.ranking[column.index]) return ``; const value = column.category ? ordinal(rank) : preference.ranking[column.index]; return `${escapeHtml(value)}`; }).join(""); return `
    ${escapeHtml(person.displayName)}${person.userId === state.data.user.userId ? `YOU` : ""}${person.loginUsername !== person.displayName ? `@${escapeHtml(person.loginUsername)}` : ""}
    ${ranks}${preference ? formatDate(preference.updatedAt) : `Pending`}`; }).join(""); shell(`
    02 / THE GROUP${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}

    Everyone’s preferences

    Explore the group’s choices. Select any column heading to sort.

    ${propertyTabs()}
    ${escapeHtml(item.name)} ${isContent ? "preferences" : "rankings"}${people.length} ${people.length === 1 ? "person" : "people"} shown
    ${isContent ? "" : `
    `}
    ${headers}${rows || ``}
    ${sortHeader("displayName", "PERSON")}${sortHeader("updated", "UPDATED")}
    No people match that search.
    `); app.querySelectorAll("[data-group-mode]").forEach((button) => button.addEventListener("click", () => { state.groupMode = button.dataset.groupMode; state.sort = { key: "displayName", direction: 1 }; render(); })); app.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => { const key = button.dataset.sort; state.sort = { key, direction: state.sort.key === key ? -state.sort.direction : 1 }; render(); })); app.querySelector("#search").addEventListener("input", (event) => { const position = event.target.selectionStart; state.search = event.target.value; render(); const input = app.querySelector("#search"); input.focus(); input.setSelectionRange(position, position); }); app.querySelector("#refresh").addEventListener("click", async () => { try { await load(true); state.notice = "Group preferences refreshed."; } catch (error) { state.notice = error.message; } render(); }); } async function logout() { try { await api("/api/logout", { method: "POST", body: "{}" }); } finally { state.data = null; state.drafts = {}; state.notice = ""; renderLogin(); } } function render() { if (!state.data?.user) { renderLogin(); } else if (state.view === "group") { renderGroup(); } else if (state.view === "account") { renderAccount(); } else { renderEditor(); } } load().catch((error) => { app.innerHTML = `

    Could not load preferences

    ${escapeHtml(error.message)}

    `; });