simplify UI to make sub-menu tabs more obvious to users; unify language

This commit is contained in:
John Stephani 2026-10-03 11:37:49 -05:00
parent 55ad5e4cc0
commit 12341b68cd
6 changed files with 123 additions and 94 deletions

View File

@ -20,12 +20,12 @@ Open <http://127.0.0.1:8000>. The SQLite database is created at `data/preference
## How it works ## How it works
- Existing login usernames are suggested as you type, before entering the shared password. The password is still required to sign in. A similar new username requires an explicit choice to create it. Login username matching ignores case. - Similar existing login usernames are suggested as you type, before entering the shared password. The name already entered is never suggested. The password is still required to sign in. A similar new username requires an explicit choice to create it. Login username matching ignores case.
- Rank the WoW Forever classes and roles you want to express a preference for. Drag a row, use its arrow buttons, or focus the drag handle and press the arrow keys. Select × to move a category to **No preference**, or Restore to rank it again. You can exclude every category. For Content, choose Yes, Maybe, or No for each of the seven activities. Save each property separately. - Rank the WoW Forever classes and roles you want to express a preference for. Drag a row, use its arrow buttons, or focus the drag handle and press the arrow keys. Select × to leave a category unranked, or select **Rank** to add it to your ranking. You can leave every category unranked. For Content, choose Yes, Maybe, or No for each of the seven activities. Save each property separately.
- Open **Group preferences** to see everyone's saved choices. Class and Role can display columns by rank or category. Excluded choices appear as blank cells and sort as the lowest preference; people who have not submitted that property appear last. Content always displays category columns. Select a column to sort, search by display name or login username, or refresh to load recent changes. When the names differ, the login username appears below the display name. - Open **Group preferences** to see everyone's saved choices. Class and Role can display columns by rank or category. Unranked choices appear as blank cells and sort after ranked choices; people who have not submitted that property appear last. Content always displays category columns. Select a column to sort, search by login username, or refresh to load recent changes.
- Open **Account** to change your login username or display name independently. Display names can be shared; login usernames must be unique. Your preferences and active sessions remain tied to your account when you rename it. You can also permanently delete your account, preferences, and sessions by typing your display name to confirm. - Open **Account** to change your login username, which is also the name shown to the group. Login usernames must be unique. Your preferences and active sessions remain tied to your account when you rename it. You can also permanently delete your account, preferences, and sessions by typing your login username to confirm.
This version uses a new database schema and does not migrate earlier preproduction data. Wipe the old database or Docker volume before starting it with existing data. Databases from the version with separate display names are migrated automatically; existing login usernames become the names shown to the group. Databases using the earlier preproduction username-only schema still require a fresh database.
The shared password does not verify who owns a username: anyone who knows it can sign in using another person's username. If this is exposed beyond a trusted group, serve it over HTTPS and set `COOKIE_SECURE=1`. The shared password does not verify who owns a username: anyone who knows it can sign in using another person's username. If this is exposed beyond a trusted group, serve it over HTTPS and set `COOKIE_SECURE=1`.

View File

@ -71,7 +71,6 @@ def user_payload(row: sqlite3.Row) -> dict:
return { return {
"userId": row["user_id"], "userId": row["user_id"],
"loginUsername": row["login_username"], "loginUsername": row["login_username"],
"displayName": row["display_name"],
} }
@ -87,9 +86,11 @@ def suggested_usernames(query: str, connection: sqlite3.Connection) -> list[str]
matches = [] matches = []
for row in connection.execute("SELECT login_key, login_username FROM users"): for row in connection.execute("SELECT login_key, login_username FROM users"):
candidate = row["login_key"] candidate = row["login_key"]
if candidate == key:
continue
similarity = SequenceMatcher(None, key, candidate).ratio() similarity = SequenceMatcher(None, key, candidate).ratio()
if candidate == key or candidate.startswith(key) or key.startswith(candidate) or similarity >= 0.64: if candidate.startswith(key) or key.startswith(candidate) or similarity >= 0.64:
score = (candidate == key, candidate.startswith(key), similarity) score = (candidate.startswith(key), similarity)
matches.append((score, row["login_username"])) matches.append((score, row["login_username"]))
matches.sort(key=lambda match: (match[0], match[1].casefold()), reverse=True) matches.sort(key=lambda match: (match[0], match[1].casefold()), reverse=True)
return [username for _, username in matches[:5]] return [username for _, username in matches[:5]]
@ -123,7 +124,6 @@ def init_db() -> None:
user_id INTEGER PRIMARY KEY, user_id INTEGER PRIMARY KEY,
login_username TEXT NOT NULL, login_username TEXT NOT NULL,
login_key TEXT NOT NULL UNIQUE, login_key TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
@ -140,6 +140,8 @@ def init_db() -> None:
); );
""" """
) )
if "display_name" in {row["name"] for row in existing}:
connection.execute("ALTER TABLE users DROP COLUMN display_name")
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@ -188,7 +190,7 @@ class Handler(BaseHTTPRequestHandler):
token_hash = hashlib.sha256(token.encode("ascii", "ignore")).hexdigest() token_hash = hashlib.sha256(token.encode("ascii", "ignore")).hexdigest()
with db() as connection: with db() as connection:
row = connection.execute( row = connection.execute(
"""SELECT u.user_id, u.login_username, u.display_name FROM sessions s """SELECT u.user_id, u.login_username FROM sessions s
JOIN users u ON u.user_id = s.user_id JOIN users u ON u.user_id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ?""", WHERE s.token_hash = ? AND s.expires_at > ?""",
(token_hash, now().isoformat()), (token_hash, now().isoformat()),
@ -234,10 +236,10 @@ class Handler(BaseHTTPRequestHandler):
(user_id,), (user_id,),
).fetchall() ).fetchall()
people_rows = connection.execute( people_rows = connection.execute(
"""SELECT u.user_id, u.login_username, u.display_name, """SELECT u.user_id, u.login_username,
p.property_id, p.preference_json, p.updated_at p.property_id, p.preference_json, p.updated_at
FROM users u LEFT JOIN preferences p ON p.user_id = u.user_id FROM users u LEFT JOIN preferences p ON p.user_id = u.user_id
ORDER BY u.display_name COLLATE NOCASE, u.login_username COLLATE NOCASE""" ORDER BY u.login_username COLLATE NOCASE"""
).fetchall() ).fetchall()
own = { own = {
row["property_id"]: preference_payload(row["property_id"], row["preference_json"], row["updated_at"]) row["property_id"]: preference_payload(row["property_id"], row["preference_json"], row["updated_at"])
@ -314,11 +316,11 @@ class Handler(BaseHTTPRequestHandler):
}) })
return return
connection.execute( connection.execute(
"INSERT OR IGNORE INTO users (login_username, login_key, display_name, created_at) VALUES (?, ?, ?, ?)", "INSERT OR IGNORE INTO users (login_username, login_key, created_at) VALUES (?, ?, ?)",
(username, key, username, now().isoformat()), (username, key, now().isoformat()),
) )
user = connection.execute( user = connection.execute(
"SELECT user_id, login_username, display_name FROM users WHERE login_key = ?", (key,) "SELECT user_id, login_username FROM users WHERE login_key = ?", (key,)
).fetchone() ).fetchone()
connection.execute( connection.execute(
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)", "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
@ -365,8 +367,8 @@ class Handler(BaseHTTPRequestHandler):
body = self._body() body = self._body()
if body is None: if body is None:
return return
if body.get("confirmDisplayName") != session["display_name"]: if body.get("confirmLoginUsername") != session["login_username"]:
self._json(HTTPStatus.BAD_REQUEST, {"error": "Type your display name exactly to confirm deletion."}) self._json(HTTPStatus.BAD_REQUEST, {"error": "Type your login username exactly to confirm deletion."})
return return
with db() as connection: with db() as connection:
connection.execute("DELETE FROM users WHERE user_id = ?", (session["user_id"],)) connection.execute("DELETE FROM users WHERE user_id = ?", (session["user_id"],))
@ -387,24 +389,22 @@ class Handler(BaseHTTPRequestHandler):
if body is None: if body is None:
return return
login_username = body.get("loginUsername") login_username = body.get("loginUsername")
display_name = body.get("displayName") if not isinstance(login_username, str):
if not isinstance(login_username, str) or not isinstance(display_name, str): self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a login username."})
self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a login username and display name."})
return return
login_username = login_username.strip() login_username = login_username.strip()
display_name = display_name.strip() if not valid_account_name(login_username):
if not valid_account_name(login_username) or not valid_account_name(display_name): self._json(HTTPStatus.BAD_REQUEST, {"error": "Username must be 1–32 printable characters."})
self._json(HTTPStatus.BAD_REQUEST, {"error": "Each name must be 1–32 printable characters."})
return return
try: try:
with db() as connection: with db() as connection:
connection.execute( connection.execute(
"""UPDATE users SET login_username = ?, login_key = ?, display_name = ? """UPDATE users SET login_username = ?, login_key = ?
WHERE user_id = ?""", WHERE user_id = ?""",
(login_username, login_key_for(login_username), display_name, session["user_id"]), (login_username, login_key_for(login_username), session["user_id"]),
) )
user = connection.execute( user = connection.execute(
"SELECT user_id, login_username, display_name FROM users WHERE user_id = ?", "SELECT user_id, login_username FROM users WHERE user_id = ?",
(session["user_id"],), (session["user_id"],),
).fetchone() ).fetchone()
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
@ -442,7 +442,7 @@ class Handler(BaseHTTPRequestHandler):
or len(value) != len(set(value)) or len(value) != len(set(value))
or not set(value).issubset(categories) or not set(value).issubset(categories)
): ):
self._json(HTTPStatus.BAD_REQUEST, {"error": "Rank each chosen category once; leave excluded categories out."}) self._json(HTTPStatus.BAD_REQUEST, {"error": "Rank each chosen category once; leave other categories unranked."})
return return
updated_at = now().isoformat() updated_at = now().isoformat()
with db() as connection: with db() as connection:

View File

@ -3,7 +3,7 @@ const state = {
view: "mine", view: "mine",
propertyId: "class", propertyId: "class",
drafts: {}, drafts: {},
sort: { key: "displayName", direction: 1 }, sort: { key: "loginUsername", direction: 1 },
groupMode: "rank", groupMode: "rank",
search: "", search: "",
busy: false, busy: false,
@ -62,7 +62,7 @@ function propertyTabs() {
} }
function shell(content) { function shell(content) {
const username = state.data.user.displayName; const username = state.data.user.loginUsername;
app.innerHTML = `<div class="site-shell"> app.innerHTML = `<div class="site-shell">
<header class="topbar"> <header class="topbar">
<div class="brand"><div class="brand-text"><strong>Bravo Team</strong><small>Forever Interest</small></div></div> <div class="brand"><div class="brand-text"><strong>Bravo Team</strong><small>Forever Interest</small></div></div>
@ -70,11 +70,12 @@ function shell(content) {
</header> </header>
<main class="main"> <main class="main">
<div class="page-heading"><div><h1>Share your preferences</h1><p>Rank your class and role choices, and tell the group which content you would play. You can update your choices any time.</p></div><div class="heading-sigil circle-icon" aria-hidden="true"><img src="/icon.webp" alt=""></div></div> <div class="page-heading"><div><h1>Share your preferences</h1><p>Rank your class and role choices, and tell the group which content you would play. You can update your choices any time.</p></div><div class="heading-sigil circle-icon" aria-hidden="true"><img src="/icon.webp" alt=""></div></div>
<nav class="main-nav" aria-label="Main navigation"> <nav class="main-nav ${state.view === "account" ? "account-nav" : ""}" aria-label="Main navigation">
<button type="button" class="nav-item ${state.view === "mine" ? "active" : ""}" data-view="mine"><span class="nav-icon">◇</span> My preferences</button> <button type="button" class="nav-item ${state.view === "mine" ? "active" : ""}" data-view="mine"><span class="nav-icon">◇</span> My preferences</button>
<button type="button" class="nav-item ${state.view === "group" ? "active" : ""}" data-view="group"><span class="nav-icon">▦</span> Group preferences <span class="nav-count">${state.data.people.length}</span></button> <button type="button" class="nav-item ${state.view === "group" ? "active" : ""}" data-view="group"><span class="nav-icon">▦</span> Group preferences <span class="nav-count">${state.data.people.length}</span></button>
<button type="button" class="nav-item ${state.view === "account" ? "active" : ""}" data-view="account"><span class="nav-icon">○</span> Account</button> <button type="button" class="nav-item ${state.view === "account" ? "active" : ""}" data-view="account"><span class="nav-icon">○</span> Account</button>
</nav> </nav>
${state.view === "account" ? "" : propertyTabs()}
${state.notice ? `<div class="notice" role="status">${escapeHtml(state.notice)}</div>` : ""} ${state.notice ? `<div class="notice" role="status">${escapeHtml(state.notice)}</div>` : ""}
${content} ${content}
</main> </main>
@ -88,7 +89,7 @@ function shell(content) {
})); }));
app.querySelectorAll("[data-property]").forEach((button) => button.addEventListener("click", () => { app.querySelectorAll("[data-property]").forEach((button) => button.addEventListener("click", () => {
state.propertyId = button.dataset.property; state.propertyId = button.dataset.property;
state.sort = { key: "displayName", direction: 1 }; state.sort = { key: "loginUsername", direction: 1 };
state.notice = ""; state.notice = "";
render(); render();
})); }));
@ -211,16 +212,14 @@ function renderEditor() {
<button type="button" class="move-button" data-move="up" data-index="${index}" aria-label="Move ${escapeHtml(category)} up" ${index === 0 ? "disabled" : ""}>↑</button> <button type="button" class="move-button" data-move="up" data-index="${index}" aria-label="Move ${escapeHtml(category)} up" ${index === 0 ? "disabled" : ""}>↑</button>
<button type="button" class="move-button" data-move="down" data-index="${index}" aria-label="Move ${escapeHtml(category)} down" ${index === draft.length - 1 ? "disabled" : ""}>↓</button> <button type="button" class="move-button" data-move="down" data-index="${index}" aria-label="Move ${escapeHtml(category)} down" ${index === draft.length - 1 ? "disabled" : ""}>↓</button>
<button type="button" class="drag-handle" data-index="${index}" aria-label="Drag ${escapeHtml(category)} to reorder; use arrow keys when focused" title="Drag to reorder">⠿</button> <button type="button" class="drag-handle" data-index="${index}" aria-label="Drag ${escapeHtml(category)} to reorder; use arrow keys when focused" title="Drag to reorder">⠿</button>
<button type="button" class="exclude-button" data-exclude="${escapeHtml(category)}" aria-label="Exclude ${escapeHtml(category)} from your ranking" title="No preference for ${escapeHtml(category)}">×</button> <button type="button" class="exclude-button" data-exclude="${escapeHtml(category)}" aria-label="Leave ${escapeHtml(category)} unranked" title="No preference for ${escapeHtml(category)}">×</button>
</div> </div>
</li>`).join(""); </li>`).join("");
const excludedCards = excluded.map((category) => `<li class="excluded-card"><span class="category-name">${escapeHtml(category)}</span><span class="excluded-tag">NO PREFERENCE</span><button type="button" class="restore-button" data-restore="${escapeHtml(category)}" aria-label="Restore ${escapeHtml(category)} to your ranking">Restore</button></li>`).join(""); const excludedCards = excluded.map((category) => `<li class="excluded-card"><span class="category-name">${escapeHtml(category)}</span><span class="excluded-tag">NO PREFERENCE</span><button type="button" class="restore-button" data-restore="${escapeHtml(category)}" aria-label="Rank ${escapeHtml(category)}">Rank</button></li>`).join("");
shell(`<section class="content-card editor-card"> shell(`<section class="content-card editor-card">
<div class="card-topline"><span class="section-kicker">01 / YOUR RANKING</span><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div> <div class="card-topline"><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
<div class="section-heading"><div><h2>Rank your ${escapeHtml(item.name.toLowerCase())} choices</h2><p>Put your favorites at the top. Select × for any choice you do not want to rank; you can restore it below.</p></div><div class="count-badge">${draft.length} RANKED</div></div> <div class="ranking-layout"><div class="ranking-labels"><span>HIGHEST RANKED</span><span>DRAG TO ORDER</span></div><ol class="rank-list" id="rank-list">${cards}</ol>${draft.length ? `<div class="least-label">LOWEST RANKED</div>` : `<p class="rank-empty">No ranked choices. Rank a category below to add it.</p>`}
${propertyTabs()} <div class="excluded-section"><div class="excluded-heading"><strong>No preference</strong><span>${excluded.length} UNRANKED</span></div><p>Unranked choices have no preference. They appear blank in group tables and sort after ranked choices.</p>${excluded.length ? `<ul class="excluded-list">${excludedCards}</ul>` : `<p class="excluded-empty">All categories are ranked.</p>`}</div>
<div class="ranking-layout"><div class="ranking-labels"><span>MOST PREFERRED</span><span>DRAG TO ORDER</span></div><ol class="rank-list" id="rank-list">${cards}</ol>${draft.length ? `<div class="least-label">LOWEST RANKED</div>` : `<p class="rank-empty">No ranked choices. Restore a category below to rank it.</p>`}
<div class="excluded-section"><div class="excluded-heading"><strong>No preference</strong><span>${excluded.length} EXCLUDED</span></div><p>Excluded choices have no rank. They appear blank in group tables and sort as the lowest preference.</p>${excluded.length ? `<ul class="excluded-list">${excludedCards}</ul>` : `<p class="excluded-empty">No categories excluded.</p>`}</div>
</div> </div>
<div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences[item.id].updatedAt)}` : "Save to share your ranking with the group."}</span><button type="button" class="primary-button" id="save" ${state.busy ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update ranking" : "Save ranking"} <span>→</span></button></div> <div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences[item.id].updatedAt)}` : "Save to share your ranking with the group."}</span><button type="button" class="primary-button" id="save" ${state.busy ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update ranking" : "Save ranking"} <span>→</span></button></div>
</section>`); </section>`);
@ -261,9 +260,7 @@ function renderContentEditor() {
</div> </div>
</div>`).join(""); </div>`).join("");
shell(`<section class="content-card editor-card"> shell(`<section class="content-card editor-card">
<div class="card-topline"><span class="section-kicker">01 / YOUR CONTENT</span><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div> <div class="card-topline"><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
<div class="section-heading"><div><h2>Choose your content interests</h2><p>Select Yes, Maybe, or No for each activity.</p></div><div class="count-badge">${answered} / ${item.categories.length} ANSWERED</div></div>
${propertyTabs()}
<div class="content-list">${rows}</div> <div class="content-list">${rows}</div>
<div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences.content.updatedAt)}` : `${answered} of ${item.categories.length} answered. Choose one response for each activity.`}</span><button type="button" class="primary-button" id="save" ${state.busy || answered !== item.categories.length ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update content" : "Save content"} <span>→</span></button></div> <div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences.content.updatedAt)}` : `${answered} of ${item.categories.length} answered. Choose one response for each activity.`}</span><button type="button" class="primary-button" id="save" ${state.busy || answered !== item.categories.length ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update content" : "Save content"} <span>→</span></button></div>
</section>`); </section>`);
@ -358,17 +355,15 @@ function formatDate(value) {
} }
function renderAccount() { function renderAccount() {
const { loginUsername, displayName } = state.data.user; const { loginUsername } = state.data.user;
shell(`<section class="content-card account-card"> shell(`<section class="content-card account-card">
<div class="card-topline"><span class="section-kicker">03 / YOUR ACCOUNT</span></div> <div class="section-heading"><div><h2>Account settings</h2><p>Change the name you sign in with and show to the group.</p></div></div>
<div class="section-heading"><div><h2>Account settings</h2><p>Change the name you sign in with or the name shown to the group.</p></div></div>
<form class="account-details" id="account-form"> <form class="account-details" id="account-form">
<div class="account-fields"><div><label for="login-username">LOGIN USERNAME</label><input id="login-username" name="loginUsername" type="text" maxlength="32" autocomplete="username" value="${escapeHtml(loginUsername)}" required><p>Unique, matched without regard to capitalization.</p></div> <div class="account-fields"><div><label for="login-username">LOGIN USERNAME</label><input id="login-username" name="loginUsername" type="text" maxlength="32" autocomplete="username" value="${escapeHtml(loginUsername)}" required><p>Shown to the group. Unique, matched without regard to capitalization.</p></div></div>
<div><label for="display-name">DISPLAY NAME</label><input id="display-name" name="displayName" type="text" maxlength="32" value="${escapeHtml(displayName)}" required><p>Shown in the group tables. Display names may be shared.</p></div></div>
<div class="account-save"><div class="form-error" role="alert"></div><button class="primary-button" type="submit">Save account changes <span>→</span></button></div> <div class="account-save"><div class="form-error" role="alert"></div><button class="primary-button" type="submit">Save account changes <span>→</span></button></div>
</form> </form>
<div class="danger-zone"><div><span class="danger-kicker">DELETE ACCOUNT</span><h3>Remove yourself from the roster</h3><p>This permanently deletes your account, all saved preferences, and all active sessions. Type <strong>${escapeHtml(displayName)}</strong> exactly to confirm.</p></div> <div class="danger-zone"><div><span class="danger-kicker">DELETE ACCOUNT</span><h3>Remove yourself from the roster</h3><p>This permanently deletes your account, all saved preferences, and all active sessions. Type <strong>${escapeHtml(loginUsername)}</strong> exactly to confirm.</p></div>
<form id="delete-form"><label for="confirm-display-name">YOUR DISPLAY NAME</label><input id="confirm-display-name" type="text" autocomplete="off" placeholder="Type ${escapeHtml(displayName)}" required><div class="form-error" id="delete-error" role="alert"></div><button class="delete-button" type="submit" disabled>Delete my account</button></form> <form id="delete-form"><label for="confirm-login-username">YOUR LOGIN USERNAME</label><input id="confirm-login-username" type="text" autocomplete="off" placeholder="Type ${escapeHtml(loginUsername)}" required><div class="form-error" id="delete-error" role="alert"></div><button class="delete-button" type="submit" disabled>Delete my account</button></form>
</div> </div>
</section>`); </section>`);
const accountForm = app.querySelector("#account-form"); const accountForm = app.querySelector("#account-form");
@ -382,7 +377,6 @@ function renderAccount() {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
loginUsername: accountForm.elements.namedItem("loginUsername").value, loginUsername: accountForm.elements.namedItem("loginUsername").value,
displayName: accountForm.elements.namedItem("displayName").value,
}), }),
}); });
await load(true); await load(true);
@ -394,18 +388,18 @@ function renderAccount() {
} }
}); });
const form = app.querySelector("#delete-form"); const form = app.querySelector("#delete-form");
const input = form.querySelector("#confirm-display-name"); const input = form.querySelector("#confirm-login-username");
const button = form.querySelector('button[type="submit"]'); const button = form.querySelector('button[type="submit"]');
input.addEventListener("input", () => { input.addEventListener("input", () => {
button.disabled = input.value !== displayName; button.disabled = input.value !== loginUsername;
form.querySelector("#delete-error").textContent = ""; form.querySelector("#delete-error").textContent = "";
}); });
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
if (input.value !== displayName) return; if (input.value !== loginUsername) return;
button.disabled = true; button.disabled = true;
try { try {
await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmDisplayName: input.value }) }); await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmLoginUsername: input.value }) });
state.data = null; state.data = null;
state.drafts = {}; state.drafts = {};
state.view = "mine"; state.view = "mine";
@ -427,7 +421,7 @@ function sortPeople(people, propertyId) {
const { key, direction } = state.sort; const { key, direction } = state.sort;
return [...people].sort((a, b) => { return [...people].sort((a, b) => {
const value = (person) => { const value = (person) => {
if (key === "displayName") return person.displayName; if (key === "loginUsername") return person.loginUsername;
const preference = person.preferences[propertyId]; const preference = person.preferences[propertyId];
if (key === "updated") return preference?.updatedAt; if (key === "updated") return preference?.updatedAt;
if (key.startsWith("category:")) { if (key.startsWith("category:")) {
@ -466,7 +460,7 @@ function renderGroup() {
const isContent = item.id === "content"; const isContent = item.id === "content";
const query = state.search.trim().toLocaleLowerCase(); const query = state.search.trim().toLocaleLowerCase();
const people = sortPeople(state.data.people.filter((person) => const people = sortPeople(state.data.people.filter((person) =>
person.displayName.toLocaleLowerCase().includes(query) || person.loginUsername.toLocaleLowerCase().includes(query) person.loginUsername.toLocaleLowerCase().includes(query)
), item.id); ), item.id);
const submitted = state.data.people.filter((person) => person.preferences[item.id]).length; const submitted = state.data.people.filter((person) => person.preferences[item.id]).length;
const columns = !isContent && state.groupMode === "rank" const columns = !isContent && state.groupMode === "rank"
@ -487,19 +481,17 @@ function renderGroup() {
const value = column.category ? ordinal(rank) : preference.ranking[column.index]; const value = column.category ? ordinal(rank) : preference.ranking[column.index];
return `<td><span class="table-choice ${rank === 1 ? "first" : ""}">${escapeHtml(value)}</span></td>`; return `<td><span class="table-choice ${rank === 1 ? "first" : ""}">${escapeHtml(value)}</span></td>`;
}).join(""); }).join("");
return `<tr><th scope="row"><div class="person-cell"><span class="person-name">${escapeHtml(person.displayName)}${person.userId === state.data.user.userId ? `<small>YOU</small>` : ""}</span>${person.loginUsername !== person.displayName ? `<span class="person-login">@${escapeHtml(person.loginUsername)}</span>` : ""}</div></th>${ranks}<td class="date-cell">${preference ? formatDate(preference.updatedAt) : `<span class="pending">Pending</span>`}</td></tr>`; return `<tr><th scope="row"><div class="person-cell"><span class="person-name">${escapeHtml(person.loginUsername)}${person.userId === state.data.user.userId ? `<small>YOU</small>` : ""}</span></div></th>${ranks}<td class="date-cell">${preference ? formatDate(preference.updatedAt) : `<span class="pending">Pending</span>`}</td></tr>`;
}).join(""); }).join("");
shell(`<section class="content-card group-card"> shell(`<section class="content-card group-card">
<div class="card-topline"><span class="section-kicker">02 / THE GROUP</span><span class="group-total">${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}</span></div> <div class="card-topline"><span class="group-total">${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}</span></div>
<div class="section-heading"><div><h2>Everyone’s preferences</h2><p>Explore the group’s choices. Select any column heading to sort.</p></div></div>
${propertyTabs()}
<div class="table-tools"><div class="table-description"><strong>${escapeHtml(item.name)} ${isContent ? "preferences" : "rankings"}</strong><span>${people.length} ${people.length === 1 ? "person" : "people"} shown</span></div><div class="table-actions">${isContent ? "" : `<div class="view-toggle" role="group" aria-label="Table columns"><button type="button" data-group-mode="rank" aria-pressed="${state.groupMode === "rank"}" class="${state.groupMode === "rank" ? "selected" : ""}">By rank</button><button type="button" data-group-mode="category" aria-pressed="${state.groupMode === "category"}" class="${state.groupMode === "category" ? "selected" : ""}">By category</button></div>`}<label class="search-box"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="Find a person" value="${escapeHtml(state.search)}" aria-label="Find a person"></label><button type="button" class="secondary-button" id="refresh">Refresh</button></div></div> <div class="table-tools"><div class="table-description"><strong>${escapeHtml(item.name)} ${isContent ? "preferences" : "rankings"}</strong><span>${people.length} ${people.length === 1 ? "person" : "people"} shown</span></div><div class="table-actions">${isContent ? "" : `<div class="view-toggle" role="group" aria-label="Table columns"><button type="button" data-group-mode="rank" aria-pressed="${state.groupMode === "rank"}" class="${state.groupMode === "rank" ? "selected" : ""}">By rank</button><button type="button" data-group-mode="category" aria-pressed="${state.groupMode === "category"}" class="${state.groupMode === "category" ? "selected" : ""}">By category</button></div>`}<label class="search-box"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="Find a person" value="${escapeHtml(state.search)}" aria-label="Find a person"></label><button type="button" class="secondary-button" id="refresh">Refresh</button></div></div>
<div class="table-scroll"><table><thead><tr><th scope="col" aria-sort="${state.sort.key === "displayName" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("displayName", "PERSON")}</th>${headers}<th scope="col" aria-sort="${state.sort.key === "updated" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("updated", "UPDATED")}</th></tr></thead><tbody>${rows || `<tr><td class="empty-table" colspan="${item.categories.length + 2}">No people match that search.</td></tr>`}</tbody></table></div> <div class="table-scroll"><table><thead><tr><th scope="col" aria-sort="${state.sort.key === "loginUsername" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("loginUsername", "PERSON")}</th>${headers}<th scope="col" aria-sort="${state.sort.key === "updated" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("updated", "UPDATED")}</th></tr></thead><tbody>${rows || `<tr><td class="empty-table" colspan="${item.categories.length + 2}">No people match that search.</td></tr>`}</tbody></table></div>
<div class="table-footer">${isContent ? "Only saved preferences appear here. A dash means the person has not completed this property yet." : "Only saved preferences appear here. A blank cell means no preference was ranked; a dash means the person has not submitted this property."}</div> <div class="table-footer">${isContent ? "Only saved preferences appear here. A dash means the person has not completed this property yet." : "Only saved preferences appear here. A blank cell means no choice was ranked there; a dash means the person has not submitted a ranking for this property."}</div>
</section>`); </section>`);
app.querySelectorAll("[data-group-mode]").forEach((button) => button.addEventListener("click", () => { app.querySelectorAll("[data-group-mode]").forEach((button) => button.addEventListener("click", () => {
state.groupMode = button.dataset.groupMode; state.groupMode = button.dataset.groupMode;
state.sort = { key: "displayName", direction: 1 }; state.sort = { key: "loginUsername", direction: 1 };
render(); render();
})); }));
app.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => { app.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {

View File

@ -39,7 +39,8 @@ h1, h2, h3, p { margin-top: 0; }
.circle-icon { display: grid; place-items: center; overflow: hidden; border: 1px solid #8c744d; border-radius: 50%; background: #e8e5dd; box-shadow: 0 0 0 4px #b9954517; } .circle-icon { display: grid; place-items: center; overflow: hidden; border: 1px solid #8c744d; border-radius: 50%; background: #e8e5dd; box-shadow: 0 0 0 4px #b9954517; }
.circle-icon img { display: block; width: 100%; height: 100%; object-fit: cover; } .circle-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
.heading-sigil { width: 74px; height: 74px; flex: 0 0 74px; } .heading-sigil { width: 74px; height: 74px; flex: 0 0 74px; }
.main-nav { display: flex; gap: 5px; border-bottom: 1px solid #33404b; margin-bottom: 24px; } .main-nav { display: flex; gap: 5px; border-bottom: 1px solid #33404b; }
.main-nav.account-nav { margin-bottom: 24px; }
.nav-item { border: 0; border-bottom: 2px solid transparent; background: none; padding: 17px 22px 18px; margin-bottom: -1px; color: #a4afb9; font-weight: 600; font-size: 19px; } .nav-item { border: 0; border-bottom: 2px solid transparent; background: none; padding: 17px 22px 18px; margin-bottom: -1px; color: #a4afb9; font-weight: 600; font-size: 19px; }
.nav-item.active { color: #f4e3bd; border-color: #d6aa59; } .nav-item.active { color: #f4e3bd; border-color: #d6aa59; }
.nav-item:hover { color: #fff; } .nav-item:hover { color: #fff; }
@ -48,7 +49,6 @@ h1, h2, h3, p { margin-top: 0; }
.notice { border: 1px solid #856c3a; background: #2a2a25; color: #f2d598; padding: 12px 16px; border-radius: 5px; font-size: 19px; margin-bottom: 16px; } .notice { border: 1px solid #856c3a; background: #2a2a25; color: #f2d598; padding: 12px 16px; border-radius: 5px; font-size: 19px; margin-bottom: 16px; }
.content-card { background: #142231; border: 1px solid #31404d; border-radius: 7px; box-shadow: 0 20px 75px rgba(0,0,0,.16); } .content-card { background: #142231; border: 1px solid #31404d; border-radius: 7px; box-shadow: 0 20px 75px rgba(0,0,0,.16); }
.card-topline { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #2c3b48; min-height: 53px; padding: 0 31px; } .card-topline { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #2c3b48; min-height: 53px; padding: 0 31px; }
.section-kicker { color: #c9a866; font-size: 15px; letter-spacing: .2em; font-weight: 700; }
.status, .group-total { color: #9eabb5; font-size: 16px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; } .status, .group-total { color: #9eabb5; font-size: 16px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
.status { display: flex; align-items: center; gap: 8px; } .status { display: flex; align-items: center; gap: 8px; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #77818a; } .status-dot { width: 7px; height: 7px; border-radius: 50%; background: #77818a; }
@ -57,8 +57,7 @@ h1, h2, h3, p { margin-top: 0; }
.section-heading { padding: 30px 31px 20px; display: flex; justify-content: space-between; align-items: center; gap: 15px; } .section-heading { padding: 30px 31px 20px; display: flex; justify-content: space-between; align-items: center; gap: 15px; }
.section-heading h2 { font: 600 25px/1.3 'Cinzel', Georgia, serif; color: #f1ebdd; letter-spacing: -.015em; margin-bottom: 7px; } .section-heading h2 { font: 600 25px/1.3 'Cinzel', Georgia, serif; color: #f1ebdd; letter-spacing: -.015em; margin-bottom: 7px; }
.section-heading p { color: #9facb7; font-size: 19px; line-height: 1.55; margin: 0; } .section-heading p { color: #9facb7; font-size: 19px; line-height: 1.55; margin: 0; }
.count-badge { white-space: nowrap; border: 1px solid #46505a; color: #bfaa7d; padding: 8px 11px; font-size: 15px; font-weight: 700; letter-spacing: .12em; } .property-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #35434e; padding: 0 31px; gap: 25px; margin: 12px 0; }
.property-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #35434e; padding: 0 31px; gap: 25px; }
.property-tab { padding: 13px 8px 14px; border: 0; border-bottom: 2px solid transparent; background: none; color: #9eacb6; font-size: 19px; font-weight: 700; margin-bottom: -1px; white-space: nowrap; } .property-tab { padding: 13px 8px 14px; border: 0; border-bottom: 2px solid transparent; background: none; color: #9eacb6; font-size: 19px; font-weight: 700; margin-bottom: -1px; white-space: nowrap; }
.property-tab.selected { color: #f0d6a0; border-color: #d8ac61; } .property-tab.selected { color: #f0d6a0; border-color: #d8ac61; }
.property-tab:hover { color: #fff; } .property-tab:hover { color: #fff; }
@ -139,7 +138,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; }
.sort-button span { font-size: 14px; color: #bc9d65; } .sort-button span { font-size: 14px; color: #bc9d65; }
.person-cell { display: grid; gap: 3px; } .person-cell { display: grid; gap: 3px; }
.person-name { color: #e8e9e7; } .person-name { color: #e8e9e7; }
.person-login { color: #91a2af; font-size: 15px; font-weight: 400; }
.person-cell small { display: inline-block; font-size: 12px; letter-spacing: .08em; color: #d8b879; margin-left: 7px; } .person-cell small { display: inline-block; font-size: 12px; letter-spacing: .08em; color: #d8b879; margin-left: 7px; }
.table-choice { color: #cbd3d7; } .table-choice { color: #cbd3d7; }
.table-choice.first { color: #f0cf8d; font-weight: 700; } .table-choice.first { color: #f0cf8d; font-weight: 700; }
@ -154,7 +152,7 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; }
.table-footer { padding: 17px 31px; color: #8d9da9; font-size: 16px; } .table-footer { padding: 17px 31px; color: #8d9da9; font-size: 16px; }
.account-details { margin: 0 31px 28px; padding: 22px 20px; background: #1b2c3a; border: 1px solid #394957; display: grid; gap: 20px; } .account-details { margin: 0 31px 28px; padding: 22px 20px; background: #1b2c3a; border: 1px solid #394957; display: grid; gap: 20px; }
.account-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } .account-fields { max-width: 420px; }
.account-details label, .danger-kicker { display: block; font-size: 15px; letter-spacing: .16em; font-weight: 700; color: #c9a866; margin-bottom: 8px; } .account-details label, .danger-kicker { display: block; font-size: 15px; letter-spacing: .16em; font-weight: 700; color: #c9a866; margin-bottom: 8px; }
.account-details input { width: 100%; height: 50px; border: 1px solid #52606a; background: #122230; color: #f2f0e8; padding: 0 13px; outline: none; border-radius: 2px; font-size: 19px; } .account-details input { width: 100%; height: 50px; border: 1px solid #52606a; background: #122230; color: #f2f0e8; padding: 0 13px; outline: none; border-radius: 2px; font-size: 19px; }
.account-details input:focus { border-color: #d9b674; } .account-details input:focus { border-color: #d9b674; }
@ -223,7 +221,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; }
.property-tabs { padding: 0 17px; } .property-tabs { padding: 0 17px; }
.section-heading h2 { font-size: 19px; } .section-heading h2 { font-size: 19px; }
.section-heading p { font-size: 16px; } .section-heading p { font-size: 16px; }
.count-badge { display: none; }
.card-footer { flex-direction: column; align-items: stretch; } .card-footer { flex-direction: column; align-items: stretch; }
.card-footer .primary-button { align-self: stretch; } .card-footer .primary-button { align-self: stretch; }
.table-tools { align-items: stretch; flex-direction: column; } .table-tools { align-items: stretch; flex-direction: column; }
@ -242,7 +239,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; }
.search-box { flex: 1; } .search-box { flex: 1; }
.search-box input { width: 100%; } .search-box input { width: 100%; }
.account-details { margin-left: 17px; margin-right: 17px; } .account-details { margin-left: 17px; margin-right: 17px; }
.account-fields { grid-template-columns: 1fr; }
.account-save { flex-direction: column; align-items: stretch; } .account-save { flex-direction: column; align-items: stretch; }
.danger-zone { padding-left: 17px; padding-right: 17px; } .danger-zone { padding-left: 17px; padding-right: 17px; }
.login-card { padding: 29px 25px; } .login-card { padding: 29px 25px; }

View File

@ -53,7 +53,7 @@ class ServerTest(unittest.TestCase):
table: [(row["from"], row["to"]) for row in connection.execute(f"PRAGMA foreign_key_list({table})")] table: [(row["from"], row["to"]) for row in connection.execute(f"PRAGMA foreign_key_list({table})")]
for table in ("sessions", "preferences") for table in ("sessions", "preferences")
} }
self.assertEqual(columns["users"], ["user_id", "login_username", "login_key", "display_name", "created_at"]) self.assertEqual(columns["users"], ["user_id", "login_username", "login_key", "created_at"])
self.assertEqual(columns["sessions"], ["token_hash", "user_id", "expires_at"]) self.assertEqual(columns["sessions"], ["token_hash", "user_id", "expires_at"])
self.assertEqual(columns["preferences"], ["user_id", "property_id", "preference_json", "updated_at"]) self.assertEqual(columns["preferences"], ["user_id", "property_id", "preference_json", "updated_at"])
self.assertEqual(foreign_keys["sessions"], [("user_id", "user_id")]) self.assertEqual(foreign_keys["sessions"], [("user_id", "user_id")])
@ -75,6 +75,39 @@ class ServerTest(unittest.TestCase):
server.DB_PATH = current_db_path server.DB_PATH = current_db_path
connection.close() connection.close()
def test_separate_display_names_migrate_without_losing_accounts(self):
old_path = Path(self.tempdir.name) / "separate-names.sqlite3"
current_db_path = server.DB_PATH
connection = sqlite3.connect(old_path)
try:
connection.execute("""CREATE TABLE users (
user_id INTEGER PRIMARY KEY, login_username TEXT NOT NULL,
login_key TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL,
created_at TEXT NOT NULL)""")
connection.execute("""CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
expires_at TEXT NOT NULL)""")
connection.execute("""CREATE TABLE preferences (
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
property_id TEXT NOT NULL, preference_json TEXT NOT NULL,
updated_at TEXT NOT NULL, PRIMARY KEY (user_id, property_id))""")
connection.execute("INSERT INTO users VALUES (1, 'Aly', 'aly', 'Other Name', 'today')")
connection.execute("INSERT INTO sessions VALUES ('token', 1, 'later')")
connection.execute("INSERT INTO preferences VALUES (1, 'role', '[\"Tank\"]', 'today')")
connection.commit()
server.DB_PATH = old_path
server.init_db()
self.assertEqual(
[row[1] for row in connection.execute("PRAGMA table_info(users)")],
["user_id", "login_username", "login_key", "created_at"],
)
self.assertEqual(connection.execute("SELECT login_username FROM users").fetchone()[0], "Aly")
self.assertEqual(connection.execute("SELECT user_id FROM sessions").fetchone()[0], 1)
self.assertEqual(connection.execute("SELECT preference_json FROM preferences").fetchone()[0], '["Tank"]')
finally:
server.DB_PATH = current_db_path
connection.close()
def test_login_save_and_group_view(self): def test_login_save_and_group_view(self):
status, data, _ = self.request("GET", "/api/bootstrap") status, data, _ = self.request("GET", "/api/bootstrap")
self.assertEqual(status, 200) self.assertEqual(status, 200)
@ -87,7 +120,7 @@ class ServerTest(unittest.TestCase):
status, data, cookie_header = self.request("POST", "/api/login", {"loginUsername": "Aly", "password": "galnik"}) status, data, cookie_header = self.request("POST", "/api/login", {"loginUsername": "Aly", "password": "galnik"})
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(data["user"]["loginUsername"], "Aly") self.assertEqual(data["user"]["loginUsername"], "Aly")
self.assertEqual(data["user"]["displayName"], "Aly") self.assertNotIn("displayName", data["user"])
aly_id = data["user"]["userId"] aly_id = data["user"]["userId"]
cookie = cookie_header.split(";", 1)[0] cookie = cookie_header.split(";", 1)[0]
@ -130,22 +163,23 @@ class ServerTest(unittest.TestCase):
status, _, second_cookie_header = self.request("POST", "/api/login", {"loginUsername": "aly", "password": "galnik"}) status, _, second_cookie_header = self.request("POST", "/api/login", {"loginUsername": "aly", "password": "galnik"})
self.assertEqual(status, 200) self.assertEqual(status, 200)
second_cookie = second_cookie_header.split(";", 1)[0] second_cookie = second_cookie_header.split(";", 1)[0]
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "Aly", "displayName": "Bex"}, cookie) status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "Aly"}, cookie)
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(data["user"]["userId"], aly_id) self.assertEqual(data["user"]["userId"], aly_id)
status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie) status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie)
self.assertEqual(len(data["people"]), 2) self.assertEqual(len(data["people"]), 2)
self.assertEqual([person["displayName"] for person in data["people"]], ["Bex", "Bex"]) self.assertEqual([person["loginUsername"] for person in data["people"]], ["Aly", "Bex"])
self.assertTrue(all("displayName" not in person for person in data["people"]))
self.assertEqual(len({person["userId"] for person in data["people"]}), 2) self.assertEqual(len({person["userId"] for person in data["people"]}), 2)
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "BEX", "displayName": "Bex"}, cookie) status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "BEX"}, cookie)
self.assertEqual(status, 409) self.assertEqual(status, 409)
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "AlyNew", "displayName": "Bex"}, cookie) status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "AlyNew"}, cookie)
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(data["user"]["userId"], aly_id) self.assertEqual(data["user"]["userId"], aly_id)
status, data, _ = self.request("GET", "/api/bootstrap", cookie=second_cookie) status, data, _ = self.request("GET", "/api/bootstrap", cookie=second_cookie)
self.assertEqual(data["user"]["loginUsername"], "AlyNew") self.assertEqual(data["user"]["loginUsername"], "AlyNew")
self.assertEqual(data["user"]["displayName"], "Bex") self.assertNotIn("displayName", data["user"])
self.assertEqual(data["preferences"]["role"]["ranking"], ranking) self.assertEqual(data["preferences"]["role"]["ranking"], ranking)
self.assertEqual(data["preferences"]["content"]["choices"], content_choices) self.assertEqual(data["preferences"]["content"]["choices"], content_choices)
status, data, _ = self.request("POST", "/api/login", {"loginUsername": "alynew", "password": "galnik"}) status, data, _ = self.request("POST", "/api/login", {"loginUsername": "alynew", "password": "galnik"})
@ -155,6 +189,11 @@ class ServerTest(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertIn("AlyNew", data["suggestions"]) self.assertIn("AlyNew", data["suggestions"])
for typed_name in ("AlyNew", "alynew"):
status, data, _ = self.request("POST", "/api/username-suggestions", {"loginUsername": typed_name})
self.assertEqual(status, 200)
self.assertNotIn("AlyNew", data["suggestions"])
status, _, _ = self.request("POST", "/api/logout", {}, cookie) status, _, _ = self.request("POST", "/api/logout", {}, cookie)
self.assertEqual(status, 204) self.assertEqual(status, 204)
status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie) status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie)
@ -224,9 +263,9 @@ class ServerTest(unittest.TestCase):
"PUT", "/api/preferences/role", {"ranking": ["DPS", "Tank", "Healer"]}, cookie "PUT", "/api/preferences/role", {"ranking": ["DPS", "Tank", "Healer"]}, cookie
) )
self.assertEqual(status, 200) self.assertEqual(status, 200)
status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "meridian"}, cookie) status, _, _ = self.request("DELETE", "/api/me", {"confirmLoginUsername": "meridian"}, cookie)
self.assertEqual(status, 400) self.assertEqual(status, 400)
status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "Meridian"}, cookie) status, _, _ = self.request("DELETE", "/api/me", {"confirmLoginUsername": "Meridian"}, cookie)
self.assertEqual(status, 204) self.assertEqual(status, 204)
status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": ["Tank", "Healer", "DPS"]}, cookie) status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": ["Tank", "Healer", "DPS"]}, cookie)

View File

@ -13,24 +13,24 @@ test("category columns sort excluded choices as lowest and pending submissions l
}); });
vm.runInContext(source, context); vm.runInContext(source, context);
context.people = [ context.people = [
{ userId: 1, loginUsername: "Third", displayName: "Third", preferences: { class: { ranking: ["Mage", "Warrior", "Paladin"] } } }, { userId: 1, loginUsername: "Third", preferences: { class: { ranking: ["Mage", "Warrior", "Paladin"] } } },
{ userId: 2, loginUsername: "First", displayName: "First", preferences: { class: { ranking: ["Paladin", "Mage", "Warrior"] } } }, { userId: 2, loginUsername: "First", preferences: { class: { ranking: ["Paladin", "Mage", "Warrior"] } } },
{ userId: 3, loginUsername: "Pending", displayName: "Pending", preferences: {} }, { userId: 3, loginUsername: "Pending", preferences: {} },
{ userId: 4, loginUsername: "Second", displayName: "Second", preferences: { class: { ranking: ["Mage", "Paladin", "Warrior"] } } }, { userId: 4, loginUsername: "Second", preferences: { class: { ranking: ["Mage", "Paladin", "Warrior"] } } },
{ userId: 5, loginUsername: "Excluded", displayName: "Excluded", preferences: { class: { ranking: ["Mage", "Warrior"] } } }, { userId: 5, loginUsername: "Excluded", preferences: { class: { ranking: ["Mage", "Warrior"] } } },
]; ];
const names = vm.runInContext( const names = vm.runInContext(
"state.sort = { key: 'category:Paladin', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)", "state.sort = { key: 'category:Paladin', direction: 1 }; sortPeople(people, 'class').map(person => person.loginUsername)",
context, context,
); );
assert.deepEqual(Array.from(names), ["First", "Second", "Third", "Excluded", "Pending"]); assert.deepEqual(Array.from(names), ["First", "Second", "Third", "Excluded", "Pending"]);
const reversed = vm.runInContext( const reversed = vm.runInContext(
"state.sort = { key: 'category:Paladin', direction: -1 }; sortPeople(people, 'class').map(person => person.displayName)", "state.sort = { key: 'category:Paladin', direction: -1 }; sortPeople(people, 'class').map(person => person.loginUsername)",
context, context,
); );
assert.deepEqual(Array.from(reversed), ["Excluded", "Third", "Second", "First", "Pending"]); assert.deepEqual(Array.from(reversed), ["Excluded", "Third", "Second", "First", "Pending"]);
const rankColumn = vm.runInContext( const rankColumn = vm.runInContext(
"state.sort = { key: 'rank2', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)", "state.sort = { key: 'rank2', direction: 1 }; sortPeople(people, 'class').map(person => person.loginUsername)",
context, context,
); );
assert.deepEqual(Array.from(rankColumn), ["Third", "First", "Second", "Excluded", "Pending"]); assert.deepEqual(Array.from(rankColumn), ["Third", "First", "Second", "Excluded", "Pending"]);
@ -51,16 +51,18 @@ test("group tables leave excluded categories and empty rank positions blank", ()
vm.runInContext(source, context); vm.runInContext(source, context);
vm.runInContext(` vm.runInContext(`
state.data = { state.data = {
user: { userId: 1, loginUsername: "Aly", displayName: "Aly" }, user: { userId: 1, loginUsername: "Aly" },
properties: [{ id: "class", name: "Class", categories: ["Paladin", "Mage", "Warrior"] }], properties: [{ id: "class", name: "Class", categories: ["Paladin", "Mage", "Warrior"] }],
preferences: { class: { ranking: ["Mage"] } }, preferences: { class: { ranking: ["Mage"] } },
people: [{ userId: 1, loginUsername: "Aly", displayName: "Aly", preferences: { class: { ranking: ["Mage"], updatedAt: "2026-10-01T00:00:00Z" } } }], people: [{ userId: 1, loginUsername: "Aly", preferences: { class: { ranking: ["Mage"], updatedAt: "2026-10-01T00:00:00Z" } } }],
}; };
state.groupMode = "category"; state.groupMode = "category";
renderGroup(); renderGroup();
`, context); `, context);
assert.equal((app.innerHTML.match(/aria-label="No preference"><\/td>/g) || []).length, 2); assert.equal((app.innerHTML.match(/aria-label="No preference"><\/td>/g) || []).length, 2);
assert.doesNotMatch(app.innerHTML, />0th</); assert.doesNotMatch(app.innerHTML, />0th</);
assert.match(app.innerHTML, /class="person-name">Aly<small>YOU<\/small>/);
assert.doesNotMatch(app.innerHTML, /person-login|undefined/);
vm.runInContext('state.groupMode = "rank"; renderGroup();', context); vm.runInContext('state.groupMode = "rank"; renderGroup();', context);
assert.equal((app.innerHTML.match(/aria-label="No ranked choice"><\/td>/g) || []).length, 2); assert.equal((app.innerHTML.match(/aria-label="No ranked choice"><\/td>/g) || []).length, 2);
}); });
@ -72,13 +74,13 @@ test("content columns sort Yes, Maybe, No and leave unanswered people last", ()
}); });
vm.runInContext(source, context); vm.runInContext(source, context);
context.people = [ context.people = [
{ userId: 1, loginUsername: "Noah", displayName: "Noah", preferences: { content: { choices: { "Group Questing": "No" } } } }, { userId: 1, loginUsername: "Noah", preferences: { content: { choices: { "Group Questing": "No" } } } },
{ userId: 2, loginUsername: "Yara", displayName: "Yara", preferences: { content: { choices: { "Group Questing": "Yes" } } } }, { userId: 2, loginUsername: "Yara", preferences: { content: { choices: { "Group Questing": "Yes" } } } },
{ userId: 3, loginUsername: "Missing", displayName: "Missing", preferences: {} }, { userId: 3, loginUsername: "Missing", preferences: {} },
{ userId: 4, loginUsername: "Mira", displayName: "Mira", preferences: { content: { choices: { "Group Questing": "Maybe" } } } }, { userId: 4, loginUsername: "Mira", preferences: { content: { choices: { "Group Questing": "Maybe" } } } },
]; ];
const names = vm.runInContext( const names = vm.runInContext(
"state.sort = { key: 'category:Group Questing', direction: 1 }; sortPeople(people, 'content').map(person => person.displayName)", "state.sort = { key: 'category:Group Questing', direction: 1 }; sortPeople(people, 'content').map(person => person.loginUsername)",
context, context,
); );
assert.deepEqual(Array.from(names), ["Yara", "Mira", "Noah", "Missing"]); assert.deepEqual(Array.from(names), ["Yara", "Mira", "Noah", "Missing"]);