From 00874e0c97f79571b5b8a6c5d3ffce00747ff3f3 Mon Sep 17 00:00:00 2001 From: j2 Date: Thu, 1 Oct 2026 19:01:17 -0500 Subject: [PATCH] MVP --- .aws | 0 .dockerignore | 7 + .gitignore | 4 + Dockerfile | 16 ++ README.md | 32 +++ compose.yaml | 14 ++ icon.webp | Bin 0 -> 1438 bytes server.py | 474 ++++++++++++++++++++++++++++++++++++ static/app.js | 554 +++++++++++++++++++++++++++++++++++++++++++ static/icon.webp | Bin 0 -> 1438 bytes static/index.html | 14 ++ static/styles.css | 249 +++++++++++++++++++ tests/test_server.py | 242 +++++++++++++++++++ tests/test_table.js | 85 +++++++ 14 files changed, 1691 insertions(+) create mode 100644 .aws create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 icon.webp create mode 100644 server.py create mode 100644 static/app.js create mode 100644 static/icon.webp create mode 100644 static/index.html create mode 100644 static/styles.css create mode 100644 tests/test_server.py create mode 100644 tests/test_table.js diff --git a/.aws b/.aws new file mode 100644 index 0000000..e69de29 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..40bfa24 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +data/ +__pycache__/ +*.pyc +.git/ +.agents/ +.codex/ +.aws/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99a01de --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data/ +__pycache__/ +*.pyc +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df9ecc6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.14-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + HOST=0.0.0.0 \ + PORT=8000 \ + PREFERENCES_DB=/app/data/preferences.sqlite3 + +WORKDIR /app +RUN useradd --system --uid 10001 --create-home app && mkdir /app/data && chown app:app /app/data +COPY --chown=app:app server.py /app/server.py +COPY --chown=app:app static /app/static +USER app + +EXPOSE 8000 +CMD ["python", "server.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..47dc612 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Bravo Team Forever Interest + +A small single-page app for collecting class and role rankings and content interests from a group. It uses Python's standard library and SQLite; there are no package dependencies. Every username uses the shared password `galnik` by default. + +## Run with Docker + +```sh +docker compose up --build -d +``` + +Open . Preferences are stored in the named Docker volume `preferences_data`, so they survive container rebuilds. To use a different shared password, set `SHARED_PASSWORD` in a local `.env` file or in the environment before starting Compose. + +## Run locally + +```sh +python3 server.py +``` + +Open . The SQLite database is created at `data/preferences.sqlite3`. You can change this with `PREFERENCES_DB`, and change the listening address or port with `HOST` and `PORT`. + +## 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. +- 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. +- 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 **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. + +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. + +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`. + +This rollout only collects and displays preferences. Assignment and optimization are planned for a later rollout. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..ca0b04f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,14 @@ +services: + preferences: + build: . + ports: + - "8000:8000" + environment: + SHARED_PASSWORD: ${SHARED_PASSWORD:-galnik} + COOKIE_SECURE: ${COOKIE_SECURE:-0} + volumes: + - preferences_data:/app/data + restart: unless-stopped + +volumes: + preferences_data: diff --git a/icon.webp b/icon.webp new file mode 100644 index 0000000000000000000000000000000000000000..babff990dbcbec3450f3f6cd6ed62a294200f91e GIT binary patch literal 1438 zcmV;P1!4M9Nk&GN1pok7MM6+kP&gop1pojL833IDDo_AW06uLfkwv5;A(}b|7(fQZ zpbQ1Ymx0`bb2k2Sv}xPGnCY|X_&4<5nV()`p1SnuJQuK)g-d7hi~x)02}S*Cab=(} z@P7WkwxWGfgqK}{IGj9(vetc+$!Rz=3_bzg^>N3y9tetnRzSR zc~)(bw3@6a7NKR{qEAy~8XV*K6Tpp6*#p!u%BI`>MS#VOn1&$yhcLAPohWIxIzUG3 zh6osW*dbol3P{)AF!L*@@3!h(cdj1AcJXuo0RH~$5jbKG83tp53#<8~meh&NTJN=C z9Z$BBXPGnvF_yj+j1+vnU}amWHHXMnaRLFFNSNQQUY02jpND^$iYyjLi%Y(ad9n5; z5pB;D-srA_KS#yc*SutJ0lmu_;Mum=s-bDYU`8K^nApixICG|)m&v_d($ep}L6q{p zp=>$LEwx^gr5!^|B5`cT>1LAtfEW&qve@$IFf{R$!Alco3I6gKc2C9BWv14O{k#7s zMkZrwPF~U#Wbze?q*Q$y)_^_T3`0a$sPY5Vt`)L%bIF-g?N$5H|OYP)s@?^FQp(bT-KnJU;EFqq5ruWbSMO;ms{&*mx+>#I zn59_YMsqZS-okt z|Nh>@zR``_TJhFPgj!=dsOa8YxUA|YqcFJON|mJUfRL3>8VRhxO)dixl!ObnIS~pf zLK#XH8~C=uX*!QUM0EB`>_2C@$5n4Gwly|Awb(<$ChOyZ$u*xnL1Q?Rd~@nc0kD(3 zswPmr!O(LIqvbHE<;$tEVeLSrCZoLl+wClbta-1c3A6tkxg=>xwX}h9PP6lo$7o2vT|+tu16*u9PJGB+n2tcKgF~1qL`-* z5wB&VgDe;n(Q@~&I}7}xcb)T}-jf*QxMuGk_9@Nvc&tV0{R6!gjNVq#1Tkm|RH)8C zZi>Q|;I}BcWApA?+$M1=k&5U?Mr_5dY}G>V^)GrNs?U!P`TV?$_bE=xzsw@QW0zkQ7c zCRVpKA>*Epkh{nh8okA!@D&*OOxu4}q;Sb`yX(g|_vOLbm=DJ1#xg0%ryjthVD{}P sI}SFrW{iJjxHl&U1XuoflDq(`FPJK9yC;;94s~1mW}DV$2 dict: + field = "choices" if property_id == "content" else "ranking" + value = json.loads(value_json) + return {field: value, "updatedAt": updated_at} + + +def login_key_for(login_username: str) -> str: + """Keep login lookup case-insensitive while retaining chosen capitalization.""" + return login_username.strip().casefold() + + +def valid_account_name(value: str) -> bool: + return 1 <= len(value) <= 32 and all(ord(char) >= 32 and ord(char) != 127 for char in value) + + +def user_payload(row: sqlite3.Row) -> dict: + return { + "userId": row["user_id"], + "loginUsername": row["login_username"], + "displayName": row["display_name"], + } + + +def password_matches(password: str) -> bool: + expected = os.environ.get("SHARED_PASSWORD", "galnik") + return hmac.compare_digest(password.encode("utf-8"), expected.encode("utf-8")) + + +def suggested_usernames(query: str, connection: sqlite3.Connection) -> list[str]: + key = login_key_for(query) + if len(key) < 2: + return [] + matches = [] + for row in connection.execute("SELECT login_key, login_username FROM users"): + candidate = row["login_key"] + similarity = SequenceMatcher(None, key, candidate).ratio() + if candidate == key or candidate.startswith(key) or key.startswith(candidate) or similarity >= 0.64: + score = (candidate == key, candidate.startswith(key), similarity) + matches.append((score, row["login_username"])) + matches.sort(key=lambda match: (match[0], match[1].casefold()), reverse=True) + return [username for _, username in matches[:5]] + + +def now() -> datetime: + return datetime.now(timezone.utc) + + +@contextmanager +def db() -> Iterator[sqlite3.Connection]: + connection = sqlite3.connect(DB_PATH, timeout=10) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + try: + with connection: + yield connection + finally: + connection.close() + + +def init_db() -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with db() as connection: + existing = connection.execute("PRAGMA table_info(users)").fetchall() + if existing and "user_id" not in {row["name"] for row in existing}: + raise RuntimeError("Existing database uses the old schema. Wipe the preproduction database before starting this version.") + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS 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 + ); + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + expires_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS 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) + ); + """ + ) + + +class Handler(BaseHTTPRequestHandler): + server_version = "Preferences/1.0" + + def _json(self, status: int, payload: dict) -> None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(data) + + def _body(self) -> dict | None: + if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json": + self._json(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "Send JSON."}) + return None + try: + length = int(self.headers.get("Content-Length", "0")) + if length < 1 or length > 8192: + raise ValueError + value = json.loads(self.rfile.read(length)) + if not isinstance(value, dict): + raise ValueError + return value + except (ValueError, json.JSONDecodeError): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Invalid request body."}) + return None + + def _same_site_write(self) -> bool: + # JSON plus this header prevents simple cross-site form submissions. + if self.headers.get("X-Requested-With") != "PreferencesSPA": + self._json(HTTPStatus.FORBIDDEN, {"error": "Invalid request."}) + return False + return True + + def _session(self) -> sqlite3.Row | None: + cookie = SimpleCookie() + try: + cookie.load(self.headers.get("Cookie", "")) + token = cookie["session"].value + except (KeyError, ValueError): + return None + token_hash = hashlib.sha256(token.encode("ascii", "ignore")).hexdigest() + with db() as connection: + row = connection.execute( + """SELECT u.user_id, u.login_username, u.display_name FROM sessions s + JOIN users u ON u.user_id = s.user_id + WHERE s.token_hash = ? AND s.expires_at > ?""", + (token_hash, now().isoformat()), + ).fetchone() + return row + + def _require_session(self) -> sqlite3.Row | None: + session = self._session() + if not session: + self._json(HTTPStatus.UNAUTHORIZED, {"error": "Please sign in."}) + return session + + def _serve_static(self, path: str) -> None: + files = { + "/": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/styles.css": ("styles.css", "text/css; charset=utf-8"), + "/icon.webp": ("icon.webp", "image/webp"), + } + if path not in files: + self.send_error(HTTPStatus.NOT_FOUND) + return + filename, content_type = files[path] + data = (STATIC / filename).read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(data) + + def do_GET(self) -> None: + path = urlsplit(self.path).path + if path == "/api/bootstrap": + session = self._session() + if not session: + self._json(HTTPStatus.OK, {"user": None, "properties": PROPERTIES}) + return + user_id = session["user_id"] + with db() as connection: + own_rows = connection.execute( + "SELECT property_id, preference_json, updated_at FROM preferences WHERE user_id = ?", + (user_id,), + ).fetchall() + people_rows = connection.execute( + """SELECT u.user_id, u.login_username, u.display_name, + p.property_id, p.preference_json, p.updated_at + 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""" + ).fetchall() + own = { + row["property_id"]: preference_payload(row["property_id"], row["preference_json"], row["updated_at"]) + for row in own_rows + } + people: dict[int, dict] = {} + for row in people_rows: + person = people.setdefault(row["user_id"], {**user_payload(row), "preferences": {}}) + if row["property_id"]: + person["preferences"][row["property_id"]] = preference_payload( + row["property_id"], row["preference_json"], row["updated_at"] + ) + self._json(HTTPStatus.OK, { + "user": user_payload(session), + "properties": PROPERTIES, + "preferences": own, + "people": list(people.values()), + }) + return + if path.startswith("/api/"): + self._json(HTTPStatus.NOT_FOUND, {"error": "Not found."}) + return + self._serve_static(path) + + def do_POST(self) -> None: + path = urlsplit(self.path).path + if not self._same_site_write(): + return + if path == "/api/username-suggestions": + body = self._body() + if body is None: + return + query = body.get("loginUsername") + if not isinstance(query, str): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a username."}) + return + if len(query) > 32: + self._json(HTTPStatus.BAD_REQUEST, {"error": "Username must be at most 32 characters."}) + return + with db() as connection: + suggestions = suggested_usernames(query, connection) + self._json(HTTPStatus.OK, {"suggestions": suggestions}) + return + if path == "/api/login": + body = self._body() + if body is None: + return + username = body.get("loginUsername") + password = body.get("password") + if not isinstance(username, str) or not isinstance(password, str): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a username and password."}) + return + username = username.strip() + if not valid_account_name(username): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Username must be 1–32 printable characters."}) + return + if not password_matches(password): + self._json(HTTPStatus.UNAUTHORIZED, {"error": "Incorrect password."}) + return + key = login_key_for(username) + token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(token.encode("ascii")).hexdigest() + expiry = now() + timedelta(days=SESSION_DAYS) + with db() as connection: + existing = connection.execute( + "SELECT user_id FROM users WHERE login_key = ?", (key,) + ).fetchone() + if not existing and body.get("createNew") is not True: + suggestions = suggested_usernames(username, connection) + if suggestions: + self._json(HTTPStatus.CONFLICT, { + "error": "Similar usernames already exist. Choose one or confirm a new account.", + "suggestions": suggestions, + }) + return + connection.execute( + "INSERT OR IGNORE INTO users (login_username, login_key, display_name, created_at) VALUES (?, ?, ?, ?)", + (username, key, username, now().isoformat()), + ) + user = connection.execute( + "SELECT user_id, login_username, display_name FROM users WHERE login_key = ?", (key,) + ).fetchone() + connection.execute( + "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)", + (token_hash, user["user_id"], expiry.isoformat()), + ) + connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (now().isoformat(),)) + data = json.dumps({"user": user_payload(user)}).encode("utf-8") + self.send_response(HTTPStatus.OK) + secure = "; Secure" if os.environ.get("COOKIE_SECURE") == "1" else "" + self.send_header("Set-Cookie", f"session={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={SESSION_DAYS * 86400}{secure}") + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + return + if path == "/api/logout": + cookie = SimpleCookie() + try: + cookie.load(self.headers.get("Cookie", "")) + token = cookie["session"].value + token_hash = hashlib.sha256(token.encode("ascii", "ignore")).hexdigest() + with db() as connection: + connection.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,)) + except (KeyError, ValueError): + pass + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Set-Cookie", "session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0") + self.send_header("Cache-Control", "no-store") + self.end_headers() + return + self._json(HTTPStatus.NOT_FOUND, {"error": "Not found."}) + + def do_DELETE(self) -> None: + path = urlsplit(self.path).path + if not self._same_site_write(): + return + if path != "/api/me": + self._json(HTTPStatus.NOT_FOUND, {"error": "Not found."}) + return + session = self._require_session() + if not session: + return + body = self._body() + if body is None: + return + if body.get("confirmDisplayName") != session["display_name"]: + self._json(HTTPStatus.BAD_REQUEST, {"error": "Type your display name exactly to confirm deletion."}) + return + with db() as connection: + connection.execute("DELETE FROM users WHERE user_id = ?", (session["user_id"],)) + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Set-Cookie", "session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0") + self.send_header("Cache-Control", "no-store") + self.end_headers() + + def do_PUT(self) -> None: + path = urlsplit(self.path).path + if not self._same_site_write(): + return + session = self._require_session() + if not session: + return + if path == "/api/me": + body = self._body() + if body is None: + return + login_username = body.get("loginUsername") + display_name = body.get("displayName") + if not isinstance(login_username, str) or not isinstance(display_name, str): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a login username and display name."}) + return + login_username = login_username.strip() + display_name = display_name.strip() + if not valid_account_name(login_username) or not valid_account_name(display_name): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Each name must be 1–32 printable characters."}) + return + try: + with db() as connection: + connection.execute( + """UPDATE users SET login_username = ?, login_key = ?, display_name = ? + WHERE user_id = ?""", + (login_username, login_key_for(login_username), display_name, session["user_id"]), + ) + user = connection.execute( + "SELECT user_id, login_username, display_name FROM users WHERE user_id = ?", + (session["user_id"],), + ).fetchone() + except sqlite3.IntegrityError: + self._json(HTTPStatus.CONFLICT, {"error": "That login username is already in use."}) + return + if not user: + self._json(HTTPStatus.UNAUTHORIZED, {"error": "Please sign in."}) + return + self._json(HTTPStatus.OK, {"user": user_payload(user)}) + return + if not path.startswith("/api/preferences/"): + self._json(HTTPStatus.NOT_FOUND, {"error": "Not found."}) + return + property_id = path.removeprefix("/api/preferences/") + property_ = PROPERTY_BY_ID.get(property_id) + if not property_: + self._json(HTTPStatus.NOT_FOUND, {"error": "Unknown property."}) + return + body = self._body() + if body is None: + return + categories = property_["categories"] + if property_id == "content": + value = body.get("choices") + if not isinstance(value, dict) or set(value) != set(categories) or any( + not isinstance(choice, str) or choice not in CONTENT_CHOICES for choice in value.values() + ): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Choose Yes, Maybe, or No for every content category."}) + return + else: + value = body.get("ranking") + if ( + not isinstance(value, list) + or any(not isinstance(item, str) for item in value) + or len(value) != len(set(value)) + or not set(value).issubset(categories) + ): + self._json(HTTPStatus.BAD_REQUEST, {"error": "Rank each chosen category once; leave excluded categories out."}) + return + updated_at = now().isoformat() + with db() as connection: + connection.execute( + """INSERT INTO preferences (user_id, property_id, preference_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, property_id) DO UPDATE SET + preference_json = excluded.preference_json, updated_at = excluded.updated_at""", + (session["user_id"], property_id, json.dumps(value), updated_at), + ) + self._json(HTTPStatus.OK, preference_payload(property_id, json.dumps(value), updated_at)) + + +def main() -> None: + init_db() + host = os.environ.get("HOST", "127.0.0.1") + port = int(os.environ.get("PORT", "8000")) + server = ThreadingHTTPServer((host, port), Handler) + print(f"Character preferences running at http://{host}:{port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..75159ef --- /dev/null +++ b/static/app.js @@ -0,0 +1,554 @@ +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} +
+
Bravo Team · Forever Interest
+
`; + 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 = ``; + 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(``); + 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 ? `` : ""}
    ${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)}

    `; +}); diff --git a/static/icon.webp b/static/icon.webp new file mode 100644 index 0000000000000000000000000000000000000000..babff990dbcbec3450f3f6cd6ed62a294200f91e GIT binary patch literal 1438 zcmV;P1!4M9Nk&GN1pok7MM6+kP&gop1pojL833IDDo_AW06uLfkwv5;A(}b|7(fQZ zpbQ1Ymx0`bb2k2Sv}xPGnCY|X_&4<5nV()`p1SnuJQuK)g-d7hi~x)02}S*Cab=(} z@P7WkwxWGfgqK}{IGj9(vetc+$!Rz=3_bzg^>N3y9tetnRzSR zc~)(bw3@6a7NKR{qEAy~8XV*K6Tpp6*#p!u%BI`>MS#VOn1&$yhcLAPohWIxIzUG3 zh6osW*dbol3P{)AF!L*@@3!h(cdj1AcJXuo0RH~$5jbKG83tp53#<8~meh&NTJN=C z9Z$BBXPGnvF_yj+j1+vnU}amWHHXMnaRLFFNSNQQUY02jpND^$iYyjLi%Y(ad9n5; z5pB;D-srA_KS#yc*SutJ0lmu_;Mum=s-bDYU`8K^nApixICG|)m&v_d($ep}L6q{p zp=>$LEwx^gr5!^|B5`cT>1LAtfEW&qve@$IFf{R$!Alco3I6gKc2C9BWv14O{k#7s zMkZrwPF~U#Wbze?q*Q$y)_^_T3`0a$sPY5Vt`)L%bIF-g?N$5H|OYP)s@?^FQp(bT-KnJU;EFqq5ruWbSMO;ms{&*mx+>#I zn59_YMsqZS-okt z|Nh>@zR``_TJhFPgj!=dsOa8YxUA|YqcFJON|mJUfRL3>8VRhxO)dixl!ObnIS~pf zLK#XH8~C=uX*!QUM0EB`>_2C@$5n4Gwly|Awb(<$ChOyZ$u*xnL1Q?Rd~@nc0kD(3 zswPmr!O(LIqvbHE<;$tEVeLSrCZoLl+wClbta-1c3A6tkxg=>xwX}h9PP6lo$7o2vT|+tu16*u9PJGB+n2tcKgF~1qL`-* z5wB&VgDe;n(Q@~&I}7}xcb)T}-jf*QxMuGk_9@Nvc&tV0{R6!gjNVq#1Tkm|RH)8C zZi>Q|;I}BcWApA?+$M1=k&5U?Mr_5dY}G>V^)GrNs?U!P`TV?$_bE=xzsw@QW0zkQ7c zCRVpKA>*Epkh{nh8okA!@D&*OOxu4}q;Sb`yX(g|_vOLbm=DJ1#xg0%ryjthVD{}P sI}SFrW{iJjxHl&U1XuoflDq(`FPJK9yC;;94s~1mW}DV$2 + + + + + + WoW Forever · Character Preferences + + + + +
    Loading preferences…
    + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..be6b386 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,249 @@ +@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap'); + +:root { + color-scheme: dark; + font-family: 'DM Sans', Arial, sans-serif; + color: #e8e9e9; + background: #0c1624; + font-synthesis: none; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; } +button, input { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .55; } +button:focus-visible, input:focus-visible { outline: 2px solid #e6bd69; outline-offset: 3px; } + +.loading, .fatal { min-height: 100vh; display: grid; place-content: center; text-align: center; } +.loading, .fatal p, .fatal button { font-size: 24px; } +.fatal button { justify-self: center; padding: 10px 20px; } +.site-shell { min-height: 100vh; background: radial-gradient(circle at 75% -10%, rgba(52, 81, 102, .16), transparent 31%), #0c1624; } +.topbar { min-height: 76px; border-bottom: 1px solid #263543; display: flex; justify-content: space-between; align-items: center; padding: 0 max(5vw, calc((100vw - 1200px) / 2)); background: rgba(12, 22, 36, .9); } +.brand { display: flex; align-items: center; } +.brand-text { display: grid; gap: 2px; } +.brand-text strong { font-family: Georgia, serif; color: #f4e3b8; font-size: 16px; letter-spacing: .13em; } +.brand-text small { color: #87919c; font-size: 8px; letter-spacing: .25em; font-weight: 700; } +.topbar-right { display: flex; align-items: center; gap: 26px; } +.account { color: #9daab5; font-size: 12px; } +.account strong { color: #e6e5de; font-weight: 600; } +.text-button { color: #dcbf83; border: 0; background: transparent; font-size: 12px; padding: 9px 0; } +.text-button:hover { color: #fff0c7; } +.main { width: min(1200px, 90vw); margin: 0 auto; padding: 61px 0 75px; } +.eyebrow { display: flex; align-items: center; gap: 10px; color: #cda763; font-size: 15px; font-weight: 700; letter-spacing: .27em; } +.eyebrow-line { width: 21px; height: 1px; background: #cda763; } +.page-heading { display: flex; justify-content: space-between; align-items: center; margin: 16px 0 34px; } +h1, h2, h3, p { margin-top: 0; } +.page-heading h1 { font: 600 clamp(30px, 3.2vw, 43px)/1.2 'Cinzel', Georgia, serif; letter-spacing: -.035em; color: #f2efe4; margin-bottom: 12px; } +.page-heading p { color: #9da9b4; font-size: 21px; line-height: 1.55; max-width: 850px; margin: 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 img { display: block; width: 100%; height: 100%; object-fit: cover; } +.heading-sigil { width: 74px; height: 74px; flex: 0 0 74px; } +.main-nav { display: flex; gap: 5px; border-bottom: 1px solid #33404b; 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.active { color: #f4e3bd; border-color: #d6aa59; } +.nav-item:hover { color: #fff; } +.nav-icon { margin-right: 7px; font-size: 18px; vertical-align: -1px; } +.nav-count { color: #d9bd85; margin-left: 8px; border: 1px solid #4d4b40; padding: 2px 7px; border-radius: 10px; font-size: 15px; } +.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); } +.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 { display: flex; align-items: center; gap: 8px; } +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #77818a; } +.status.saved .status-dot { background: #72b58d; } +.status.changed .status-dot { background: #e6bd69; } +.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 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; } +.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:hover { color: #fff; } +.ranking-layout { padding: 28px 31px 30px; } +.ranking-labels { display: flex; justify-content: space-between; padding: 0 1px 9px; color: #a9b6c0; font-size: 14px; letter-spacing: .19em; font-weight: 700; } +.rank-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.rank-card { min-height: 68px; background: #1c2d3d; border: 1px solid #3a4b58; border-radius: 4px; display: flex; align-items: center; padding: 0 13px 0 18px; position: relative; transition: border-color .15s, background .15s; } +.rank-card:first-child { border-color: #a9834c; background: #29323a; } +.rank-card:hover { border-color: #a88d5d; } +.rank-card.dragging { z-index: 3; background: #2a3d4c; border-color: #e3bb70; box-shadow: 0 12px 28px #060b12a6; transition: none; } +.rank-number { font: 600 19px Georgia, serif; color: #9da6ae; width: 44px; } +.rank-card:first-child .rank-number { color: #e5bd72; } +.category-name { font-size: 21px; font-weight: 600; color: #e8e9e7; flex: 1; } +.rank-actions { display: flex; gap: 1px; align-items: center; } +.move-button, .drag-handle { display: grid; place-items: center; border: 0; background: transparent; color: #a5b3bc; height: 40px; width: 38px; padding: 0; font-size: 24px; } +.move-button:hover, .drag-handle:hover { color: #f5d28e; } +.drag-handle { font-size: 30px; cursor: grab; touch-action: none; } +.drag-handle:active { cursor: grabbing; } +.exclude-button { display: grid; place-items: center; flex: 0 0 40px; height: 40px; margin-left: 5px; border: 1px solid #805c5b; border-radius: 3px; background: #342c33; color: #f0b5a9; font-size: 27px; line-height: 1; } +.exclude-button:hover { background: #58383b; color: #ffe1d8; } +.least-label { color: #8896a1; font-size: 14px; letter-spacing: .18em; font-weight: 700; padding-top: 13px; } +.rank-empty { color: #a5b2bc; font-size: 18px; margin: 8px 0 0; } +.excluded-section { margin-top: 27px; border-top: 1px solid #3b4852; padding-top: 22px; } +.excluded-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.excluded-heading strong { color: #e7d1c9; font: 600 22px Georgia, serif; } +.excluded-heading span { color: #c3948a; font-size: 14px; font-weight: 700; letter-spacing: .13em; } +.excluded-section > p { margin: 8px 0 17px; color: #aab6be; font-size: 17px; line-height: 1.5; } +.excluded-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.excluded-card { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-height: 61px; padding: 9px 13px 9px 18px; border: 1px dashed #675452; border-radius: 4px; background: #252b34; } +.excluded-card .category-name { color: #b7afb0; } +.excluded-tag { color: #c59b90; font-size: 13px; font-weight: 700; letter-spacing: .1em; } +.restore-button { border: 1px solid #75654f; border-radius: 3px; background: #29343d; color: #ead0a0; padding: 7px 10px; font-size: 16px; font-weight: 700; } +.restore-button:hover { background: #3a4650; } +.excluded-section .excluded-empty { margin: 0; color: #8797a2; font-size: 16px; } +.content-list { display: grid; gap: 9px; padding: 27px 31px 30px; } +.content-row { min-height: 64px; background: #1c2d3d; border: 1px solid #3a4b58; border-radius: 4px; padding: 10px 15px 10px 19px; display: flex; align-items: center; justify-content: space-between; gap: 18px; } +.content-name { color: #e8e9e7; font-size: 21px; font-weight: 600; } +.content-options { display: flex; flex: 0 0 auto; gap: 5px; } +.content-option { min-width: 80px; min-height: 44px; border: 1px solid #53616d; border-radius: 3px; background: #172634; color: #aebbc5; font-size: 18px; font-weight: 700; } +.content-option:hover { border-color: #b99b68; color: #fff; } +.content-option.selected.yes { border-color: #82b99b; background: #28493e; color: #cdf2d8; } +.content-option.selected.maybe { border-color: #c5a86b; background: #4a4030; color: #f1deb2; } +.content-option.selected.no { border-color: #a9807c; background: #493736; color: #f2cfca; } +.card-footer { min-height: 84px; border-top: 1px solid #34424d; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 31px; color: #9cabb6; font-size: 16px; } +.primary-button { display: inline-flex; align-items: center; justify-content: center; gap: 30px; background: linear-gradient(#eac57e, #c69951); border: 1px solid #f2d695; border-radius: 3px; color: #1a2228; font-size: 18px; font-weight: 700; padding: 12px 17px; min-height: 48px; box-shadow: inset 0 1px #fff5bc55; } +.primary-button:hover:not(:disabled) { filter: brightness(1.09); } +.primary-button span { font-size: 17px; line-height: 1; } +.footer { color: #627480; text-align: center; font-size: 15px; letter-spacing: .08em; padding: 23px; } +.footer span { color: #b58d4e; margin: 0 5px; } + +.group-card .section-heading { padding-bottom: 27px; } +.table-tools { display: flex; justify-content: space-between; align-items: center; gap: 15px; padding: 22px 31px 19px; } +.table-description { display: flex; gap: 13px; align-items: center; } +.table-description strong { color: #eadbbd; font: 600 24px Georgia, serif; } +.table-description span { color: #95a5b0; font-size: 16px; } +.table-actions { display: flex; align-items: center; gap: 8px; } +.view-toggle { display: flex; border: 1px solid #52606a; background: #1b2a37; height: 44px; padding: 2px; border-radius: 3px; white-space: nowrap; } +.view-toggle button { border: 0; background: transparent; color: #a8b6bf; font-size: 16px; font-weight: 700; padding: 0 10px; border-radius: 2px; } +.view-toggle button.selected { background: #43515a; color: #f1d393; } +.view-toggle button:hover:not(.selected) { color: #fff; } +.search-box { display: flex; align-items: center; height: 44px; border: 1px solid #465460; background: #1a2a39; padding: 0 10px; color: #a7b5bf; } +.search-box span { font-size: 23px; line-height: 1; } +.search-box input { border: none; outline: none; background: transparent; color: #f0f1ef; width: 170px; font-size: 18px; } +.search-box input::placeholder { color: #8797a4; } +.secondary-button { color: #e4c790; border: 1px solid #796342; background: #26313a; padding: 8px 12px; height: 44px; font-size: 16px; font-weight: 700; border-radius: 2px; } +.secondary-button:hover { background: #34424b; } +.table-scroll { overflow-x: auto; border-top: 1px solid #34434f; } +table { width: 100%; border-collapse: collapse; white-space: nowrap; text-align: left; } +thead { background: #1a2b3a; } +th, td { border-bottom: 1px solid #2e3e4c; padding: 14px 13px; font-size: 18px; } +thead th { padding: 0; } +thead th:first-child, tbody th { position: sticky; left: 0; z-index: 1; min-width: 170px; } +thead th:first-child { background: #1a2b3a; z-index: 2; } +tbody th { background: #142231; font-weight: 600; } +tbody tr:hover td, tbody tr:hover th { background: #203242; } +.sort-button { width: 100%; min-height: 52px; padding: 8px 13px; display: flex; align-items: center; justify-content: space-between; gap: 14px; text-align: left; border: 0; background: none; color: #9eacb7; font-size: 15px; font-weight: 700; letter-spacing: .09em; } +.sort-button:hover, .sort-button.active { color: #e8c781; } +.sort-button span { font-size: 14px; color: #bc9d65; } +.person-cell { display: grid; gap: 3px; } +.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; } +.table-choice { color: #cbd3d7; } +.table-choice.first { color: #f0cf8d; font-weight: 700; } +.content-table-choice { display: inline-block; min-width: 68px; text-align: center; border-radius: 3px; padding: 5px 8px; font-size: 16px; font-weight: 700; } +.content-table-choice.yes { color: #cdf2d8; background: #28493e; } +.content-table-choice.maybe { color: #f1deb2; background: #4a4030; } +.content-table-choice.no { color: #f2cfca; background: #493736; } +.table-dash { color: #647482; } +.date-cell { color: #9aaab5; } +.pending { color: #83929d; font-style: italic; } +.empty-table { text-align: center; padding: 35px; color: #a2b0b9; } +.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-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.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:focus { border-color: #d9b674; } +.account-details p { margin: 8px 0 0; color: #9facb7; font-size: 17px; line-height: 1.45; } +.account-save { display: flex; justify-content: space-between; align-items: center; gap: 16px; } +.account-save .form-error { flex: 1; min-height: 0; padding: 0; } +.danger-zone { border-top: 1px solid #34424d; padding: 30px 31px 35px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(230px, 320px); gap: 34px; } +.danger-kicker { color: #e49e8b; } +.danger-zone h3 { font: 600 19px/1.35 'Cinzel', Georgia, serif; color: #f1e5dc; margin: 10px 0; } +.danger-zone p { color: #adb8bf; font-size: 18px; line-height: 1.55; margin: 0; max-width: 490px; } +.danger-zone p strong { color: #f1e5dc; } +.danger-zone label { display: block; color: #d5b6ab; font-size: 15px; font-weight: 700; letter-spacing: .1em; margin-bottom: 8px; } +.danger-zone input { width: 100%; height: 48px; border: 1px solid #725b59; background: #192834; color: #f2eee9; padding: 0 12px; border-radius: 2px; outline: none; font-size: 19px; } +.danger-zone input:focus { border-color: #e6a693; } +.delete-button { background: #673e3b; color: #ffe2da; border: 1px solid #b46e65; border-radius: 3px; padding: 10px 15px; font-size: 18px; font-weight: 700; } +.delete-button:hover:not(:disabled) { background: #854c46; } + +.login-page { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; position: relative; isolation: isolate; overflow-x: hidden; background: radial-gradient(ellipse at 50% 39%, #234359 0, #102638 38%, #0b1726 72%); padding: 35px 16px; } +.login-page::before { content: ''; position: absolute; inset: 0; z-index: -2; opacity: .28; background: repeating-linear-gradient(125deg, transparent 0 95px, #7c9bae10 96px 97px); } +.login-glow { position: absolute; z-index: -1; width: 640px; height: 640px; border: 1px solid #b9954533; border-radius: 50%; box-shadow: 0 0 0 85px #ad8b3410, 0 0 0 170px #ad8b3408; top: 50%; left: 50%; transform: translate(-50%, -50%); } +.login-card { width: min(460px, 100%); border: 1px solid #6d624f; background: linear-gradient(145deg, #1b3040ee, #111e2bee); box-shadow: 0 30px 100px #0009, inset 0 1px #ccad6a33; padding: 34px 48px 38px; text-align: center; } +.login-emblem { width: 80px; height: 80px; margin: 0 auto 13px; } +.eyebrow.center { display: block; color: #d1b173; } +.login-card h1 { color: #f4eddb; font: 600 34px/1.3 'Cinzel', Georgia, serif; margin: 18px 0; } +.login-card h1 em { color: #eac686; font-style: normal; } +.login-intro { color: #afbdc6; font-size: 18px; line-height: 1.55; max-width: 360px; margin: 0 auto 29px; } +.login-card form { text-align: left; } +.login-card label { display: block; color: #d3bd90; font-size: 15px; font-weight: 700; letter-spacing: .13em; margin: 17px 0 8px; } +.login-card input { width: 100%; border: 1px solid #52606a; background: #122230; color: #f2f0e8; height: 50px; padding: 0 13px; outline: none; border-radius: 2px; font-size: 19px; } +.login-card input:focus { border-color: #d9b674; } +.login-card input::placeholder { color: #748a99; } +.username-suggestions { text-align: left; } +.username-suggestions:empty { display: none; } +.username-suggestions p { color: #d9c595; font-size: 16px; line-height: 1.4; margin: 13px 0 7px; } +.suggestion-list { display: flex; flex-wrap: wrap; gap: 6px; } +.suggestion-item { border: 1px solid #776b55; color: #e8d9ae; background: #283744; border-radius: 3px; padding: 6px 9px; font-size: 16px; } +.suggestion-item:hover { background: #384958; } +.create-anyway { display: block; border: 0; background: transparent; color: #dda78b; font-size: 16px; text-decoration: underline; padding: 9px 0 0; } +.form-success { color: #a3dfba; font-size: 16px; padding-bottom: 10px; } +.form-error { min-height: 30px; padding-top: 8px; color: #ffa795; font-size: 16px; } +.primary-button.full { width: 100%; justify-content: space-between; min-height: 52px; } +.login-footnote { border-top: 1px solid #3d4b53; margin-top: 26px; padding-top: 20px; color: #8fa0ac; font-size: 16px; } +.login-bottom { color: #738693; font-size: 14px; letter-spacing: .17em; margin-top: 25px; } +.login-bottom span { color: #d4ad64; margin: 0 6px; } + +@media (max-width: 800px) { + .heading-sigil { display: none; } + .main { width: min(100% - 32px, 700px); padding-top: 40px; } + .danger-zone { grid-template-columns: 1fr; gap: 22px; } + .table-tools { align-items: stretch; flex-direction: column; } +} +@media (max-width: 560px) { + .topbar { padding: 0 16px; min-height: 68px; } + .brand-text strong { font-size: 12px; } + .brand-text small { font-size: 6px; } + .topbar-right { gap: 9px; } + .account { display: none; } + .page-heading h1 { font-size: 27px; } + .page-heading p { font-size: 18px; } + .nav-item { padding: 13px 10px; font-size: 16px; } + .main-nav { overflow-x: auto; } + .nav-item { white-space: nowrap; } + .nav-icon { display: none; } + .nav-count { margin-left: 2px; } + .card-topline, .section-heading, .ranking-layout, .content-list, .card-footer, .table-tools, .table-footer { padding-left: 17px; padding-right: 17px; } + .property-tabs { padding: 0 17px; } + .section-heading h2 { font-size: 19px; } + .section-heading p { font-size: 16px; } + .count-badge { display: none; } + .card-footer { flex-direction: column; align-items: stretch; } + .card-footer .primary-button { align-self: stretch; } + .table-tools { align-items: stretch; flex-direction: column; } + .table-actions { width: 100%; } + .table-actions { flex-wrap: wrap; } + .content-row { align-items: stretch; flex-direction: column; gap: 10px; } + .content-options { width: 100%; } + .content-option { flex: 1; min-width: 0; } + .rank-card { padding-left: 10px; padding-right: 8px; } + .rank-number { width: 34px; } + .rank-actions { display: grid; grid-template-columns: repeat(2, 34px); gap: 2px; } + .move-button, .drag-handle, .exclude-button { width: 34px; height: 34px; } + .exclude-button { margin-left: 0; } + .view-toggle { width: 100%; } + .view-toggle button { flex: 1; } + .search-box { flex: 1; } + .search-box input { width: 100%; } + .account-details { margin-left: 17px; margin-right: 17px; } + .account-fields { grid-template-columns: 1fr; } + .account-save { flex-direction: column; align-items: stretch; } + .danger-zone { padding-left: 17px; padding-right: 17px; } + .login-card { padding: 29px 25px; } +} diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..902557e --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,242 @@ +import http.client +import json +import sqlite3 +import tempfile +import threading +import unittest +from pathlib import Path +from http.server import ThreadingHTTPServer + +import server + + +class ServerTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tempdir = tempfile.TemporaryDirectory() + cls.original_db_path = server.DB_PATH + server.DB_PATH = Path(cls.tempdir.name) / "preferences.sqlite3" + server.init_db() + cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler) + cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.httpd.shutdown() + cls.httpd.server_close() + cls.thread.join() + server.DB_PATH = cls.original_db_path + cls.tempdir.cleanup() + + def request(self, method, path, body=None, cookie=None): + connection = http.client.HTTPConnection("127.0.0.1", self.httpd.server_port) + headers = {} + if body is not None: + headers.update({"Content-Type": "application/json", "X-Requested-With": "PreferencesSPA"}) + if cookie: + headers["Cookie"] = cookie + connection.request(method, path, body=json.dumps(body) if body is not None else None, headers=headers) + response = connection.getresponse() + payload = response.read() + result = response.status, json.loads(payload) if payload else None, response.getheader("Set-Cookie") + connection.close() + return result + + def test_schema_uses_stable_user_ids(self): + with server.db() as connection: + columns = { + table: [row["name"] for row in connection.execute(f"PRAGMA table_info({table})")] + for table in ("users", "sessions", "preferences") + } + foreign_keys = { + table: [(row["from"], row["to"]) for row in connection.execute(f"PRAGMA foreign_key_list({table})")] + for table in ("sessions", "preferences") + } + self.assertEqual(columns["users"], ["user_id", "login_username", "login_key", "display_name", "created_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(foreign_keys["sessions"], [("user_id", "user_id")]) + self.assertEqual(foreign_keys["preferences"], [("user_id", "user_id")]) + + def test_old_schema_requires_a_fresh_database(self): + old_path = Path(self.tempdir.name) / "old.sqlite3" + current_db_path = server.DB_PATH + connection = sqlite3.connect(old_path) + try: + connection.execute("CREATE TABLE users (username_key TEXT PRIMARY KEY, username TEXT, created_at TEXT)") + connection.execute("INSERT INTO users VALUES ('aly', 'Aly', 'today')") + connection.commit() + server.DB_PATH = old_path + with self.assertRaisesRegex(RuntimeError, "Wipe the preproduction database"): + server.init_db() + self.assertEqual(connection.execute("SELECT username FROM users").fetchone()[0], "Aly") + finally: + server.DB_PATH = current_db_path + connection.close() + + def test_login_save_and_group_view(self): + status, data, _ = self.request("GET", "/api/bootstrap") + self.assertEqual(status, 200) + self.assertIsNone(data["user"]) + self.assertEqual(len(data["properties"][0]["categories"]), 9) + + status, _, _ = self.request("POST", "/api/login", {"loginUsername": "Aly", "password": "wrong"}) + self.assertEqual(status, 401) + + status, data, cookie_header = self.request("POST", "/api/login", {"loginUsername": "Aly", "password": "galnik"}) + self.assertEqual(status, 200) + self.assertEqual(data["user"]["loginUsername"], "Aly") + self.assertEqual(data["user"]["displayName"], "Aly") + aly_id = data["user"]["userId"] + cookie = cookie_header.split(";", 1)[0] + + bad_ranking = ["Tank", "DPS", "DPS"] + status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": bad_ranking}, cookie) + self.assertEqual(status, 400) + + ranking = ["Healer", "DPS", "Tank"] + status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": ranking}, cookie) + self.assertEqual(status, 200) + + class_ranking = list(reversed(server.PROPERTIES[0]["categories"])) + status, _, _ = self.request("PUT", "/api/preferences/class", {"ranking": class_ranking}, cookie) + self.assertEqual(status, 200) + + content_categories = server.PROPERTY_BY_ID["content"]["categories"] + content_choices = {category: "Yes" for category in content_categories} + content_choices["Open World PvP"] = "Maybe" + content_choices["40-Man Raids"] = "No" + status, _, _ = self.request("PUT", "/api/preferences/content", {"choices": {"Group Questing": "Yes"}}, cookie) + self.assertEqual(status, 400) + status, _, _ = self.request("PUT", "/api/preferences/content", {"choices": {**content_choices, "40-Man Raids": "Later"}}, cookie) + self.assertEqual(status, 400) + status, _, _ = self.request("PUT", "/api/preferences/content", {"choices": content_choices}, cookie) + self.assertEqual(status, 200) + + status, _, _ = self.request("POST", "/api/login", {"loginUsername": "Bex", "password": "galnik"}) + self.assertEqual(status, 200) + + status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie) + self.assertEqual(status, 200) + self.assertEqual(data["preferences"]["role"]["ranking"], ranking) + self.assertEqual(data["preferences"]["class"]["ranking"], class_ranking) + self.assertEqual(data["preferences"]["content"]["choices"], content_choices) + self.assertEqual(len(data["people"]), 2) + self.assertEqual(data["people"][0]["preferences"]["role"]["ranking"], ranking) + self.assertEqual(data["people"][0]["preferences"]["content"]["choices"], content_choices) + self.assertEqual(data["people"][1]["preferences"], {}) + + status, _, second_cookie_header = self.request("POST", "/api/login", {"loginUsername": "aly", "password": "galnik"}) + self.assertEqual(status, 200) + second_cookie = second_cookie_header.split(";", 1)[0] + status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "Aly", "displayName": "Bex"}, cookie) + self.assertEqual(status, 200) + self.assertEqual(data["user"]["userId"], aly_id) + status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie) + self.assertEqual(len(data["people"]), 2) + self.assertEqual([person["displayName"] for person in data["people"]], ["Bex", "Bex"]) + self.assertEqual(len({person["userId"] for person in data["people"]}), 2) + + status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "BEX", "displayName": "Bex"}, cookie) + self.assertEqual(status, 409) + status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "AlyNew", "displayName": "Bex"}, cookie) + self.assertEqual(status, 200) + self.assertEqual(data["user"]["userId"], aly_id) + status, data, _ = self.request("GET", "/api/bootstrap", cookie=second_cookie) + self.assertEqual(data["user"]["loginUsername"], "AlyNew") + self.assertEqual(data["user"]["displayName"], "Bex") + self.assertEqual(data["preferences"]["role"]["ranking"], ranking) + self.assertEqual(data["preferences"]["content"]["choices"], content_choices) + status, data, _ = self.request("POST", "/api/login", {"loginUsername": "alynew", "password": "galnik"}) + self.assertEqual(status, 200) + self.assertEqual(data["user"]["userId"], aly_id) + status, data, _ = self.request("POST", "/api/username-suggestions", {"loginUsername": "AlyNe"}) + self.assertEqual(status, 200) + self.assertIn("AlyNew", data["suggestions"]) + + status, _, _ = self.request("POST", "/api/logout", {}, cookie) + self.assertEqual(status, 204) + status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie) + self.assertIsNone(data["user"]) + + def test_partial_and_empty_rankings(self): + status, _, cookie_header = self.request( + "POST", "/api/login", {"loginUsername": "Sparse", "password": "galnik"} + ) + self.assertEqual(status, 200) + cookie = cookie_header.split(";", 1)[0] + for ranking in (["Paladin", "Paladin"], ["Paladin", "Unknown"], [7], {"Paladin": 1}): + status, _, _ = self.request("PUT", "/api/preferences/class", {"ranking": ranking}, cookie) + self.assertEqual(status, 400) + + status, data, _ = self.request("PUT", "/api/preferences/class", {"ranking": ["Paladin", "Mage"]}, cookie) + self.assertEqual(status, 200) + self.assertEqual(data["ranking"], ["Paladin", "Mage"]) + status, data, _ = self.request("PUT", "/api/preferences/role", {"ranking": []}, cookie) + self.assertEqual(status, 200) + self.assertEqual(data["ranking"], []) + status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie) + self.assertEqual(data["preferences"]["class"]["ranking"], ["Paladin", "Mage"]) + self.assertEqual(data["preferences"]["role"]["ranking"], []) + + def test_username_suggestions_and_account_deletion(self): + status, data, _ = self.request( + "POST", "/api/username-suggestions", {"loginUsername": "Merid"} + ) + self.assertEqual(status, 200) + self.assertEqual(data["suggestions"], []) + status, _, _ = self.request( + "POST", "/api/login", {"loginUsername": "Meridian", "password": "gálnik"} + ) + self.assertEqual(status, 401) + + status, data, cookie_header = self.request( + "POST", "/api/login", {"loginUsername": "Meridian", "password": "galnik"} + ) + self.assertEqual(status, 200) + meridian_id = data["user"]["userId"] + cookie = cookie_header.split(";", 1)[0] + status, data, _ = self.request( + "POST", "/api/username-suggestions", {"loginUsername": "Meridain"} + ) + self.assertEqual(status, 200) + self.assertIn("Meridian", data["suggestions"]) + + status, data, _ = self.request( + "POST", "/api/login", {"loginUsername": "Meridain", "password": "galnik"} + ) + self.assertEqual(status, 409) + self.assertIn("Meridian", data["suggestions"]) + + status, data, _ = self.request( + "POST", "/api/login", {"loginUsername": "mERIDIAN", "password": "galnik"} + ) + self.assertEqual(status, 200) + self.assertEqual(data["user"]["loginUsername"], "Meridian") + + status, _, _ = self.request( + "POST", "/api/login", {"loginUsername": "Meridain", "password": "galnik", "createNew": True} + ) + self.assertEqual(status, 200) + + status, _, _ = self.request( + "PUT", "/api/preferences/role", {"ranking": ["DPS", "Tank", "Healer"]}, cookie + ) + self.assertEqual(status, 200) + status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "meridian"}, cookie) + self.assertEqual(status, 400) + status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "Meridian"}, cookie) + self.assertEqual(status, 204) + + status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": ["Tank", "Healer", "DPS"]}, cookie) + self.assertEqual(status, 401) + with server.db() as connection: + self.assertIsNone(connection.execute("SELECT 1 FROM users WHERE login_key = ?", ("meridian",)).fetchone()) + self.assertIsNone(connection.execute("SELECT 1 FROM preferences WHERE user_id = ?", (meridian_id,)).fetchone()) + self.assertIsNone(connection.execute("SELECT 1 FROM sessions WHERE user_id = ?", (meridian_id,)).fetchone()) + self.assertIsNotNone(connection.execute("SELECT 1 FROM users WHERE login_key = ?", ("meridain",)).fetchone()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_table.js b/tests/test_table.js new file mode 100644 index 0000000..9af74f6 --- /dev/null +++ b/tests/test_table.js @@ -0,0 +1,85 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const source = fs.readFileSync(path.join(__dirname, "../static/app.js"), "utf8"); + +test("category columns sort excluded choices as lowest and pending submissions last", () => { + const context = vm.createContext({ + document: { querySelector: () => ({}) }, + fetch: () => new Promise(() => {}), + }); + vm.runInContext(source, context); + context.people = [ + { userId: 1, loginUsername: "Third", displayName: "Third", preferences: { class: { ranking: ["Mage", "Warrior", "Paladin"] } } }, + { userId: 2, loginUsername: "First", displayName: "First", preferences: { class: { ranking: ["Paladin", "Mage", "Warrior"] } } }, + { userId: 3, loginUsername: "Pending", displayName: "Pending", preferences: {} }, + { userId: 4, loginUsername: "Second", displayName: "Second", preferences: { class: { ranking: ["Mage", "Paladin", "Warrior"] } } }, + { userId: 5, loginUsername: "Excluded", displayName: "Excluded", preferences: { class: { ranking: ["Mage", "Warrior"] } } }, + ]; + const names = vm.runInContext( + "state.sort = { key: 'category:Paladin', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)", + context, + ); + assert.deepEqual(Array.from(names), ["First", "Second", "Third", "Excluded", "Pending"]); + const reversed = vm.runInContext( + "state.sort = { key: 'category:Paladin', direction: -1 }; sortPeople(people, 'class').map(person => person.displayName)", + context, + ); + assert.deepEqual(Array.from(reversed), ["Excluded", "Third", "Second", "First", "Pending"]); + const rankColumn = vm.runInContext( + "state.sort = { key: 'rank2', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)", + context, + ); + assert.deepEqual(Array.from(rankColumn), ["Third", "First", "Second", "Excluded", "Pending"]); + assert.equal(vm.runInContext("ordinal(1)", context), "1st"); + assert.equal(vm.runInContext("ordinal(12)", context), "12th"); +}); + +test("group tables leave excluded categories and empty rank positions blank", () => { + const app = { + innerHTML: "", + querySelector: () => ({ addEventListener() {} }), + querySelectorAll: () => [], + }; + const context = vm.createContext({ + document: { querySelector: () => app }, + fetch: () => new Promise(() => {}), + }); + vm.runInContext(source, context); + vm.runInContext(` + state.data = { + user: { userId: 1, loginUsername: "Aly", displayName: "Aly" }, + properties: [{ id: "class", name: "Class", categories: ["Paladin", "Mage", "Warrior"] }], + preferences: { class: { ranking: ["Mage"] } }, + people: [{ userId: 1, loginUsername: "Aly", displayName: "Aly", preferences: { class: { ranking: ["Mage"], updatedAt: "2026-10-01T00:00:00Z" } } }], + }; + state.groupMode = "category"; + renderGroup(); + `, context); + assert.equal((app.innerHTML.match(/aria-label="No preference"><\/td>/g) || []).length, 2); + assert.doesNotMatch(app.innerHTML, />0th<\/td>/g) || []).length, 2); +}); + +test("content columns sort Yes, Maybe, No and leave unanswered people last", () => { + const context = vm.createContext({ + document: { querySelector: () => ({}) }, + fetch: () => new Promise(() => {}), + }); + vm.runInContext(source, context); + context.people = [ + { userId: 1, loginUsername: "Noah", displayName: "Noah", preferences: { content: { choices: { "Group Questing": "No" } } } }, + { userId: 2, loginUsername: "Yara", displayName: "Yara", preferences: { content: { choices: { "Group Questing": "Yes" } } } }, + { userId: 3, loginUsername: "Missing", displayName: "Missing", preferences: {} }, + { userId: 4, loginUsername: "Mira", displayName: "Mira", preferences: { content: { choices: { "Group Questing": "Maybe" } } } }, + ]; + const names = vm.runInContext( + "state.sort = { key: 'category:Group Questing', direction: 1 }; sortPeople(people, 'content').map(person => person.displayName)", + context, + ); + assert.deepEqual(Array.from(names), ["Yara", "Mira", "Noah", "Missing"]); +});