From 1e65d8930d41794ecbc89138465b761c405f6e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sun, 1 Mar 2026 11:44:21 +0100 Subject: [PATCH] sync current state --- .env.example | 6 + .gitignore | 5 + .htaccess | 10 + algorithm.js | 67 ++++ api.php | 389 +++++++++++++++++++++ app.js | 650 ++++++++++++++++++++++++++++++++++++ bun.lock | 178 ++++++++++ deploy.sh | 44 +++ icon.svg | 7 + index.html | 57 ++++ index.php | 23 ++ manifest.webmanifest | 17 + mise.toml | 2 + package.json | 21 ++ public/manifest.webmanifest | 17 + round-state.js | 35 ++ scripts/copy-assets.ts | 6 + setup-db.sql | 48 +++ setup-server.sh | 60 ++++ src/client/algorithm.ts | 79 +++++ src/client/input.css | 161 +++++++++ src/client/main.ts | 551 ++++++++++++++++++++++++++++++ src/client/round-state.ts | 34 ++ src/server/api.ts | 197 +++++++++++ src/server/db.ts | 182 ++++++++++ src/server/index.ts | 128 +++++++ src/server/types.ts | 24 ++ styles.css | 296 ++++++++++++++++ tests/algorithm.test.mjs | 45 +++ tests/algorithm.test.ts | 29 ++ tests/round-state.test.mjs | 34 ++ tests/round-state.test.ts | 22 ++ tsconfig.json | 12 + 33 files changed, 3436 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 algorithm.js create mode 100644 api.php create mode 100644 app.js create mode 100644 bun.lock create mode 100755 deploy.sh create mode 100644 icon.svg create mode 100644 index.html create mode 100644 index.php create mode 100644 manifest.webmanifest create mode 100644 mise.toml create mode 100644 package.json create mode 100644 public/manifest.webmanifest create mode 100644 round-state.js create mode 100644 scripts/copy-assets.ts create mode 100644 setup-db.sql create mode 100755 setup-server.sh create mode 100644 src/client/algorithm.ts create mode 100644 src/client/input.css create mode 100644 src/client/main.ts create mode 100644 src/client/round-state.ts create mode 100644 src/server/api.ts create mode 100644 src/server/db.ts create mode 100644 src/server/index.ts create mode 100644 src/server/types.ts create mode 100644 styles.css create mode 100644 tests/algorithm.test.mjs create mode 100644 tests/algorithm.test.ts create mode 100644 tests/round-state.test.mjs create mode 100644 tests/round-state.test.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e100be9 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=serve +DB_PASS=yourpassword +DB_NAME=serve +PORT=3001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ab0775 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.DS_Store +data/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..ff97f4b --- /dev/null +++ b/.htaccess @@ -0,0 +1,10 @@ +RewriteEngine On +RewriteBase /movies/ +DirectoryIndex index.php + +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +RewriteRule ^api/?$ api.php [L,QSA] +RewriteRule ^ index.php [L,QSA] diff --git a/algorithm.js b/algorithm.js new file mode 100644 index 0000000..1faf34f --- /dev/null +++ b/algorithm.js @@ -0,0 +1,67 @@ +export function paretoFilter(movies, people, ratings) { + return movies.filter((movieA) => { + return !movies.some((movieB) => { + if (movieA === movieB) { + return false; + } + let allAtLeastAsGood = true; + let strictlyBetter = false; + for (const person of people) { + const a = ratings[person][movieA]; + const b = ratings[person][movieB]; + if (b < a) { + allAtLeastAsGood = false; + break; + } + if (b > a) { + strictlyBetter = true; + } + } + return allAtLeastAsGood && strictlyBetter; + }); + }); +} + +export function nashScore(movie, people, ratings) { + let product = 1; + for (const person of people) { + product *= ratings[person][movie] + 1; + } + return product; +} + +export function averageScore(movie, people, ratings) { + let total = 0; + for (const person of people) { + total += ratings[person][movie]; + } + return total / people.length; +} + +export function decideMovie({ movies, people, ratings }) { + if (movies.length < 1 || people.length < 1) { + throw new Error("Need at least one movie and one person"); + } + const remaining = paretoFilter(movies, people, ratings); + const scored = remaining.map((movie) => { + return { + movie, + nash: nashScore(movie, people, ratings), + avg: averageScore(movie, people, ratings), + }; + }); + scored.sort((a, b) => { + if (b.nash !== a.nash) { + return b.nash - a.nash; + } + if (b.avg !== a.avg) { + return b.avg - a.avg; + } + return a.movie.localeCompare(b.movie); + }); + return { + winner: scored[0], + remaining, + ranking: scored, + }; +} diff --git a/api.php b/api.php new file mode 100644 index 0000000..b72f675 --- /dev/null +++ b/api.php @@ -0,0 +1,389 @@ + false, 'error' => $message], JSON_UNESCAPED_UNICODE); + exit; +} + +function clean_user(string $value): string { + $trimmed = trim($value); + if ($trimmed === '' || mb_strlen($trimmed) > 30) { + fail(400, 'Invalid user'); + } + return $trimmed; +} + +function clean_movie(string $value): string { + $trimmed = trim($value); + // Strip leading trophy emoji if present (e.g., "πŸ† Matrix" -> "Matrix") + $trimmed = preg_replace('/^(?:\x{1F3C6}\s*)+/u', '', $trimmed); + if ($trimmed === '' || mb_strlen($trimmed) > 60) { + fail(400, 'Invalid movie title'); + } + return $trimmed; +} + +function normalize_users(array $users): array { + $normalized = []; + foreach ($users as $user) { + if (!is_string($user)) { + continue; + } + $name = trim($user); + if ($name === '' || mb_strlen($name) > 30) { + continue; + } + if (!in_array($name, $normalized, true)) { + $normalized[] = $name; + } + } + return $normalized; +} + +function normalize_done_users(array $doneUsers, array $users): array { + $normalized = []; + foreach ($doneUsers as $name) { + if (!is_string($name)) { + continue; + } + if (!in_array($name, $users, true)) { + continue; + } + if (!in_array($name, $normalized, true)) { + $normalized[] = $name; + } + } + return $normalized; +} + +function all_users_done(array $users, array $doneUsers): bool { + if (count($users) === 0) { + return false; + } + foreach ($users as $name) { + if (!in_array($name, $doneUsers, true)) { + return false; + } + } + return true; +} + +function load_state(string $uuid): array { + $path = DATA_DIR . '/' . $uuid . '.json'; + if (!file_exists($path)) { + return [ + 'uuid' => $uuid, + 'phase' => 1, + 'setupDone' => false, + 'users' => [], + 'doneUsersPhase1' => [], + 'doneUsersPhase2' => [], + 'movies' => [], + 'votes' => new stdClass(), + 'updatedAt' => gmdate('c'), + 'createdAt' => gmdate('c'), + ]; + } + $raw = file_get_contents($path); + if ($raw === false) { + fail(500, 'Could not read state'); + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + fail(500, 'Corrupt state file'); + } + $decoded['setupDone'] = (bool)($decoded['setupDone'] ?? false); + $decoded['users'] = normalize_users(is_array($decoded['users'] ?? null) ? $decoded['users'] : []); + $decoded['doneUsersPhase1'] = normalize_done_users(is_array($decoded['doneUsersPhase1'] ?? null) ? $decoded['doneUsersPhase1'] : [], $decoded['users']); + $decoded['doneUsersPhase2'] = normalize_done_users(is_array($decoded['doneUsersPhase2'] ?? null) ? $decoded['doneUsersPhase2'] : [], $decoded['users']); + $decoded['movies'] = is_array($decoded['movies'] ?? null) ? $decoded['movies'] : []; + $decoded['votes'] = is_array($decoded['votes'] ?? null) ? $decoded['votes'] : []; + $decoded['phase'] = (int)($decoded['phase'] ?? 1); + if ($decoded['phase'] !== 1 && $decoded['phase'] !== 2 && $decoded['phase'] !== 3) { + $decoded['phase'] = 1; + } + $decoded['history'] = is_array($decoded['history'] ?? null) ? $decoded['history'] : []; + return $decoded; +} + +function save_state(string $uuid, array $state): void { + $state['updatedAt'] = gmdate('c'); + $path = DATA_DIR . '/' . $uuid . '.json'; + $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if ($json === false) { + fail(500, 'Could not encode state'); + } + if (file_put_contents($path, $json, LOCK_EX) === false) { + fail(500, 'Could not save state'); + } +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$body = []; +if ($method === 'POST') { + $rawBody = file_get_contents('php://input'); + if ($rawBody !== false && $rawBody !== '') { + $parsed = json_decode($rawBody, true); + if (is_array($parsed)) { + $body = $parsed; + } + } +} + +$action = $_GET['action'] ?? ($body['action'] ?? 'state'); +$uuid = $_GET['uuid'] ?? ($body['uuid'] ?? ''); + +if (!preg_match(UUID_PATTERN, $uuid)) { + fail(400, 'Invalid UUID'); +} + +$state = load_state($uuid); + +if ($action === 'state') { + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($method !== 'POST') { + fail(405, 'Use POST for mutations'); +} + +if ($action === 'add_user') { + if ((int)$state['phase'] !== 1 || $state['setupDone']) { + fail(409, 'Setup is closed'); + } + $user = clean_user((string)($body['user'] ?? '')); + if (!in_array($user, $state['users'], true)) { + $state['users'][] = $user; + } + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($action === 'finish_setup') { + if (count($state['users']) < 1) { + fail(409, 'Add at least one user first'); + } + $state['setupDone'] = true; + $state['phase'] = 1; + $state['doneUsersPhase1'] = []; + $state['doneUsersPhase2'] = []; + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($action === 'add_movie') { + if (!$state['setupDone']) { + fail(409, 'Setup is not finished'); + } + if ((int)$state['phase'] !== 1) { + fail(409, 'Movie phase is closed'); + } + $user = clean_user((string)($body['user'] ?? '')); + if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) { + fail(403, 'Unknown user'); + } + $title = clean_movie((string)($body['title'] ?? '')); + $userMovieCount = 0; + foreach ($state['movies'] as $movie) { + if (!is_array($movie)) { + continue; + } + if (mb_strtolower((string)($movie['addedBy'] ?? '')) === mb_strtolower($user)) { + $userMovieCount++; + } + } + if ($userMovieCount >= 5) { + fail(409, 'Movie limit reached for this user (5)'); + } + foreach ($state['movies'] as $existing) { + if (mb_strtolower((string)($existing['title'] ?? '')) === mb_strtolower($title)) { + fail(409, 'Movie already exists'); + } + } + $state['movies'][] = [ + 'title' => $title, + 'addedBy' => $user, + 'addedAt' => gmdate('c'), + ]; + $state['doneUsersPhase1'] = array_values(array_filter( + $state['doneUsersPhase1'], + static fn(string $name): bool => $name !== $user, + )); + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($action === 'remove_movie') { + if (!$state['setupDone']) { + fail(409, 'Setup is not finished'); + } + if ((int)$state['phase'] !== 1) { + fail(409, 'Movie phase is closed'); + } + $user = clean_user((string)($body['user'] ?? '')); + if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) { + fail(403, 'Unknown user'); + } + $title = clean_movie((string)($body['title'] ?? '')); + $state['movies'] = array_values(array_filter( + $state['movies'], + static fn(array $m): bool => !(mb_strtolower((string)($m['title'] ?? '')) === mb_strtolower($title) && mb_strtolower((string)($m['addedBy'] ?? '')) === mb_strtolower($user)), + )); + $state['doneUsersPhase1'] = array_values(array_filter( + $state['doneUsersPhase1'], + static fn(string $name): bool => $name !== $user, + )); + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($action === 'vote_many') { + if ((int)$state['phase'] !== 2) { + fail(409, 'Voting phase is not active'); + } + $user = clean_user((string)($body['user'] ?? '')); + if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) { + fail(403, 'Unknown user'); + } + $ratings = $body['ratings'] ?? null; + if (!is_array($ratings)) { + fail(400, 'Ratings payload missing'); + } + $movieTitles = array_map(static fn(array $movie): string => (string)$movie['title'], $state['movies']); + if (!isset($state['votes'][$user]) || !is_array($state['votes'][$user])) { + $state['votes'][$user] = []; + } + foreach ($movieTitles as $title) { + $rawRating = $ratings[$title] ?? null; + if (!is_int($rawRating)) { + if (is_numeric($rawRating)) { + $rawRating = (int)$rawRating; + } else { + fail(400, 'Each movie needs a rating'); + } + } + if ($rawRating < 0 || $rawRating > 5) { + fail(400, 'Rating must be 0 to 5'); + } + $state['votes'][$user][$title] = $rawRating; + } + $state['doneUsersPhase2'] = array_values(array_filter( + $state['doneUsersPhase2'], + static fn(string $name): bool => $name !== $user, + )); + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($action === 'mark_done') { + if (!$state['setupDone']) { + fail(409, 'Setup is not finished'); + } + $user = clean_user((string)($body['user'] ?? '')); + if (count($state['users']) > 0 && !in_array($user, $state['users'], true)) { + fail(403, 'Unknown user'); + } + $phase = (int)($body['phase'] ?? $state['phase']); + if ($phase === 1) { + if ((int)$state['phase'] !== 1) { + fail(409, 'Movie phase is not active'); + } + if (!in_array($user, $state['doneUsersPhase1'], true)) { + $state['doneUsersPhase1'][] = $user; + } + if (all_users_done($state['users'], $state['doneUsersPhase1'])) { + $state['phase'] = 2; + $state['doneUsersPhase2'] = []; + } + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; + } + if ($phase === 2) { + if ((int)$state['phase'] !== 2) { + fail(409, 'Voting phase is not active'); + } + $movieTitles = array_map(static fn(array $movie): string => (string)$movie['title'], $state['movies']); + if (!isset($state['votes'][$user]) || !is_array($state['votes'][$user])) { + fail(409, 'Save your ratings first'); + } + foreach ($movieTitles as $title) { + $value = $state['votes'][$user][$title] ?? null; + if (!is_int($value)) { + fail(409, 'Rate every movie before finishing'); + } + } + if (!in_array($user, $state['doneUsersPhase2'], true)) { + $state['doneUsersPhase2'][] = $user; + } + if (all_users_done($state['users'], $state['doneUsersPhase2'])) { + $state['phase'] = 3; + } + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; + } + fail(400, 'Invalid done phase'); +} + +if ($action === 'set_phase') { + if (!$state['setupDone']) { + fail(409, 'Finish setup first'); + } + $phase = (int)($body['phase'] ?? 0); + if ($phase !== 1 && $phase !== 2 && $phase !== 3) { + fail(400, 'Invalid phase'); + } + $state['phase'] = $phase; + if ($phase === 1) { + $state['doneUsersPhase1'] = []; + $state['doneUsersPhase2'] = []; + } + if ($phase === 2) { + $state['doneUsersPhase2'] = []; + } + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($action === 'new_round') { + if ((int)$state['phase'] !== 3) { + fail(409, 'Round is not finished yet'); + } + $winner = trim((string)($body['winner'] ?? '')); + // strip trophy emoji if present + $winner = preg_replace('/^(?:\x{1F3C6}\s*)+/u', '', $winner); + $state['history'][] = [ + 'movies' => $state['movies'], + 'winner' => $winner !== '' ? $winner : null, + ]; + $state['phase'] = 1; + $state['movies'] = []; + $state['votes'] = new stdClass(); + $state['doneUsersPhase1'] = []; + $state['doneUsersPhase2'] = []; + save_state($uuid, $state); + echo json_encode(['ok' => true, 'state' => $state], JSON_UNESCAPED_UNICODE); + exit; +} + +fail(404, 'Unknown action'); diff --git a/app.js b/app.js new file mode 100644 index 0000000..8734f61 --- /dev/null +++ b/app.js @@ -0,0 +1,650 @@ +import { decideMovie } from "/movies/algorithm.js"; +import { collectCompleteRatings } from "/movies/round-state.js"; + +const mount = document.getElementById("app"); +const path = window.location.pathname.replace(/^\/movies\/?/, ""); +const segments = path.split("/").filter(Boolean); +const uuid = segments[0] || ""; +const isAdmin = segments[1] === "admin"; +const params = new URLSearchParams(window.location.search); +let user = (params.get("user") || "").trim(); +let state = null; +let draftRatings = {}; +let adminUserDraft = ""; +let busy = false; + +function isValidUuid(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function apiUrl(action) { + return `/movies/api?action=${encodeURIComponent(action)}&uuid=${encodeURIComponent(uuid)}`; +} + +async function apiGetState() { + const res = await fetch(apiUrl("state")); + return res.json(); +} + +async function apiPost(action, body) { + const res = await fetch(apiUrl(action), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, action, uuid }), + }); + const data = await res.json(); + if (!res.ok || !data.ok) { + throw new Error(data.error || "Request failed"); + } + return data; +} + +function escapeHtml(text) { + return text.replace(/[&<>"']/g, (char) => { + const map = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return map[char] || char; + }); +} + +function showError(message) { + const target = mount.querySelector("#error"); + if (!target) { + return; + } + target.hidden = false; + target.textContent = message; +} + +function doneUsersForPhase(phase) { + if (phase === 1) { + return state.doneUsersPhase1 || []; + } + if (phase === 2) { + return state.doneUsersPhase2 || []; + } + return []; +} + +function isUserDone(phase, name) { + if (!name) { + return false; + } + return doneUsersForPhase(phase).includes(name); +} + +function computeRoundResult() { + const movies = state.movies.map((m) => m.title); + if (movies.length === 0) { + return { kind: "no_movies" }; + } + const participants = state.users; + const ratings = collectCompleteRatings(participants, movies, state.votes || {}); + const voters = Object.keys(ratings); + if (voters.length === 0) { + return { kind: "no_votes" }; + } + return { + kind: "ok", + voterCount: voters.length, + totalUsers: participants.length, + result: decideMovie({ movies, people: voters, ratings }), + }; +} + +function resultSection(title) { + const info = computeRoundResult(); + if (info.kind === "no_movies") { + return `

${escapeHtml(title)}

No movies were added.

`; + } + if (info.kind === "no_votes") { + return `

${escapeHtml(title)}

No complete votes yet.

`; + } + return `

${escapeHtml(title)}

Winner: ${escapeHtml(info.result.winner.movie)}

Complete voters: ${info.voterCount}/${info.totalUsers}

    ${info.result.ranking.map((r) => `
  1. ${escapeHtml(r.movie)} (Nash ${r.nash.toFixed(2)})
  2. `).join("")}
`; +} + +function buildCreateScreen() { + mount.innerHTML = ` +

Create Round

+

Create a new round, add users, then share each user link.

+
+ +
+ `; + mount.querySelector("#createRound").addEventListener("click", () => { + const id = crypto.randomUUID(); + window.location.href = `/movies/${id}/admin`; + }); +} + +function renderAdminSetup() { + mount.innerHTML = ` +

Add Users

+

Add all users, then press Next to start the round.

+
+ + +
+ +
+ +
+ + `; + const userInput = mount.querySelector("#userInput"); + userInput.value = adminUserDraft; + userInput.focus(); + userInput.addEventListener("input", () => { + adminUserDraft = userInput.value; + }); + mount.querySelector("#userList").innerHTML = state.users.map((name) => `
  • ${escapeHtml(name)}
  • `).join(""); + const addUser = async () => { + const name = adminUserDraft.trim(); + if (!name || busy) { + return; + } + if (busy) { + return; + } + try { + busy = true; + await apiPost("add_user", { user: name }); + adminUserDraft = ""; + draftRatings = {}; + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }; + mount.querySelector("#addUser").addEventListener("click", addUser); + userInput.addEventListener("keydown", async (event) => { + if (event.key !== "Enter") { + return; + } + event.preventDefault(); + await addUser(); + }); + mount.querySelector("#nextSetup").addEventListener("click", async () => { + if (busy) { + return; + } + try { + busy = true; + await apiPost("finish_setup", {}); + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }); +} + +function renderAdminStatus() { + const donePhase1 = state.doneUsersPhase1 || []; + const donePhase2 = state.doneUsersPhase2 || []; + const totalSteps = state.users.length * 2; + const completedSteps = donePhase1.length + donePhase2.length; + const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0; + const base = window.location.origin; + const shareRows = state.users.map((name) => { + const link = `${base}/movies/${uuid}?user=${encodeURIComponent(name)}`; + const userSteps = (donePhase1.includes(name) ? 1 : 0) + (donePhase2.includes(name) ? 1 : 0); + return `
  • ${escapeHtml(name)} (${userSteps}/2)
  • `; + }).join(""); + + let phaseTitle = ""; + if (state.phase === 1) { + phaseTitle = "Phase 1: Movie Collection"; + } else if (state.phase === 2) { + phaseTitle = "Phase 2: Voting"; + } else { + phaseTitle = "Phase 3: Result"; + } + const phasePanel = ` +
    +

    ${escapeHtml(phaseTitle)}

    +

    Overall progress: ${completedSteps}/${totalSteps} user-steps (${progressPercent}%).

    +
    +
    +
    +
    + `; + + mount.innerHTML = ` +

    Admin Status

    + ${phasePanel} +

    Invite Links

    + +
    + +
    + ${state.phase === 3 ? resultSection("Final Result") : ""} + ${state.phase === 3 ? `
    ` : ""} + + `; + mount.querySelectorAll(".copyLink").forEach((button) => { + button.addEventListener("click", async () => { + const link = button.dataset.link; + try { + await navigator.clipboard.writeText(link); + button.textContent = "Copied"; + window.setTimeout(() => { + button.textContent = "Copy"; + }, 1200); + } catch { + showError("Clipboard permission failed"); + } + }); + }); + mount.querySelector("#refreshAdmin").addEventListener("click", async () => { + try { + await loadAndRender(); + } catch (error) { + showError(error.message); + } + }); + if (state.phase === 3) { + mount.querySelector("#newRound").addEventListener("click", async () => { + if (busy) { + return; + } + try { + busy = true; + const info = computeRoundResult(); + const winner = info.kind === "ok" ? info.result.winner.movie : ""; + await apiPost("new_round", { winner }); + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }); + } +} + +function renderUserWaitingPhase(phase) { + const donePhase1 = state.doneUsersPhase1 || []; + const donePhase2 = state.doneUsersPhase2 || []; + const totalSteps = state.users.length * 2; + const completedSteps = donePhase1.length + donePhase2.length; + const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0; + const phaseTitle = phase === 1 ? "Phase 1: Movie Collection" : "Phase 2: Voting"; + const waitingText = phase === 1 + ? "You are done with movie collection. Waiting for other users." + : "You are done with voting. Waiting for other users."; + mount.innerHTML = ` +

    Waiting

    +
    +

    ${escapeHtml(phaseTitle)}

    +

    Overall progress: ${completedSteps}/${totalSteps} user-steps (${progressPercent}%).

    +
    +
    +
    +
    +

    ${escapeHtml(waitingText)}

    +
    + +
    + + `; + mount.querySelector("#refreshUser").addEventListener("click", async () => { + await loadAndRender(); + }); +} + +function buildPrevMoviesSection(remaining) { + const history = state.history || []; + if (history.length === 0) { + return ""; + } + // normalize current titles (strip trophy if stored) + const currentTitles = new Set(state.movies.map((m) => (m.title || "").replace(/^πŸ†\s*/, "").toLowerCase())); + + const wonMovies = []; + const regularMovies = new Set(); + + for (let i = history.length - 1; i >= 0; i--) { + const round = history[i]; + if (round.winner) { + wonMovies.push(round.winner); + } + for (const m of (round.movies || [])) { + const raw = (m.title || "").replace(/^πŸ†\s*/, ""); + regularMovies.add(raw); + } + } + + const wonTitleSet = new Set(wonMovies.map((t) => (t || "").replace(/^πŸ†\s*/, "").toLowerCase())); + + const regularList = Array.from(regularMovies) + .filter((title) => !wonTitleSet.has(title.toLowerCase())) + .filter((title) => !currentTitles.has(title.toLowerCase())) + .sort((a, b) => a.localeCompare(b)); + + const seenWon = new Set(); + const wonList = []; + for (const w of wonMovies) { + const clean = (w || "").replace(/^πŸ†\s*/, ""); + const key = clean.toLowerCase(); + if (!clean || seenWon.has(key) || currentTitles.has(key)) { + continue; + } + seenWon.add(key); + wonList.push({ display: `πŸ† ${clean}`, value: clean }); + } + + const items = [ + ...regularList.map((t) => ({ display: t, value: t })), + ...wonList, + ]; + + if (items.length === 0) { + return ""; + } + + const chips = items + .map((item) => { + const disabled = remaining <= 0 ? " disabled" : ""; + return `
  • `; + }) + .join(""); + return `

    Available Movies

    `; +} + +function renderMoviePhase() { + const canAddMovies = user && state.users.includes(user); + const myMovieCount = canAddMovies ? state.movies.filter((m) => m.addedBy.toLowerCase() === user.toLowerCase()).length : 0; + const remaining = Math.max(0, 5 - myMovieCount); + const done = isUserDone(1, user); + if (done) { + renderUserWaitingPhase(1); + return; + } + mount.innerHTML = ` +

    Add Movies

    +

    Phase 1 of 3: add up to 5 movies, then press Done.

    + ${canAddMovies ? `

    Your movie slots left: ${remaining}

    ` : `

    Use your personal user link.

    `} + ${canAddMovies ? `
    ` : ""} + ${canAddMovies && myMovieCount > 0 ? `

    Your Selection (tap to remove)

    ` : ""} + + ${canAddMovies ? buildPrevMoviesSection(remaining) : ""} + ${canAddMovies ? `
    ` : ""} + + `; + mount.querySelector("#movieList").innerHTML = state.movies + .filter((m) => canAddMovies && m.addedBy.toLowerCase() === user.toLowerCase()) + .map((m) => `
  • `) + .join(""); + if (!canAddMovies) { + return; + } + const addMovie = async () => { + const input = mount.querySelector("#movieInput"); + const title = input.value.trim(); + if (!title || busy) { + return; + } + try { + busy = true; + await apiPost("add_movie", { user, title }); + input.value = ""; + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }; + mount.querySelector("#addMovie").addEventListener("click", addMovie); + mount.querySelector("#movieInput").focus(); + mount.querySelector("#movieInput").addEventListener("keydown", async (event) => { + if (event.key !== "Enter") { + return; + } + event.preventDefault(); + await addMovie(); + }); + mount.querySelectorAll(".prevMovie").forEach((btn) => { + btn.addEventListener("click", async () => { + if (busy || btn.disabled) { + return; + } + const title = btn.dataset.title; + try { + busy = true; + await apiPost("add_movie", { user, title }); + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }); + }); + mount.querySelectorAll(".userMovie").forEach((btn) => { + btn.addEventListener("click", async () => { + if (busy) { + return; + } + const title = btn.dataset.title; + try { + busy = true; + await apiPost("remove_movie", { user, title }); + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }); + }); + mount.querySelector("#markDone").addEventListener("click", async () => { + if (busy) { + return; + } + try { + busy = true; + await apiPost("mark_done", { user, phase: 1 }); + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }); + mount.querySelector("#refreshUser").addEventListener("click", async () => { + await loadAndRender(); + }); +} + +function renderVotingPhase() { + const movies = state.movies.map((m) => m.title); + if (!state.votes[user]) { + state.votes[user] = {}; + } + const done = isUserDone(2, user); + if (done) { + renderUserWaitingPhase(2); + return; + } + const rows = movies + .map((title) => { + const current = draftRatings[title] ?? state.votes[user][title] ?? 3; + return `${escapeHtml(title)}
    ${current}
    `; + }) + .join(""); + + mount.innerHTML = ` +

    Vote

    +

    Phase 2 of 3: rate every movie, then press Done.

    + + + ${rows} +
    MovieYour Rating
    +
    + + + +
    + + `; + + mount.querySelectorAll(".rating").forEach((input) => { + input.addEventListener("input", () => { + const title = input.dataset.title; + const rating = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10))); + input.value = String(rating); + draftRatings[title] = rating; + const valueLabel = input.closest(".ratingRow")?.querySelector(".ratingValue"); + if (valueLabel) { + valueLabel.textContent = String(rating); + } + }); + }); + + mount.querySelector("#saveVotes").addEventListener("click", async () => { + if (busy) { + return; + } + try { + busy = true; + const ratings = {}; + mount.querySelectorAll(".rating").forEach((input) => { + const title = input.dataset.title; + ratings[title] = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10))); + }); + await apiPost("vote_many", { user, ratings }); + draftRatings = {}; + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }); + + mount.querySelector("#doneVoting").addEventListener("click", async () => { + if (busy) { + return; + } + try { + busy = true; + const ratings = {}; + mount.querySelectorAll(".rating").forEach((input) => { + const title = input.dataset.title; + ratings[title] = Math.max(0, Math.min(5, Number.parseInt(input.value || "0", 10))); + }); + await apiPost("vote_many", { user, ratings }); + await apiPost("mark_done", { user, phase: 2 }); + draftRatings = {}; + await loadAndRender(); + } catch (error) { + showError(error.message); + } finally { + busy = false; + } + }); + + mount.querySelector("#refreshUser").addEventListener("click", async () => { + await loadAndRender(); + }); +} + +function renderFinalPage() { + mount.innerHTML = ` +

    Final Result

    +

    Phase 3 of 3: voting is complete.

    + ${resultSection("Round Outcome")} +
    + +
    + + `; + mount.querySelector("#refreshUser").addEventListener("click", async () => { + await loadAndRender(); + }); +} + +async function loadAndRender() { + const data = await apiGetState(); + state = data.state; + if (!state.setupDone && isAdmin) { + renderAdminSetup(); + return; + } + if (!state.setupDone) { + mount.innerHTML = `

    Admin is still setting up users. Open your personal link when ready.

    `; + mount.querySelector("#refreshUser").addEventListener("click", async () => { + await loadAndRender(); + }); + return; + } + if (isAdmin) { + renderAdminStatus(); + return; + } + if (!user) { + askUserName(); + return; + } + if (state.users.length > 0 && !state.users.includes(user)) { + mount.innerHTML = `

    Unknown user. Use one of the shared links from admin.

    `; + return; + } + if (state.phase === 1) { + renderMoviePhase(); + return; + } + if (state.phase === 2) { + renderVotingPhase(); + return; + } + renderFinalPage(); +} + +function askUserName() { + mount.innerHTML = ` +

    Who are you?

    +

    Use the exact name from your invite link.

    +
    + + +
    + `; + mount.querySelector("#userInput").focus(); + mount.querySelector("#saveUser").addEventListener("click", () => { + const value = mount.querySelector("#userInput").value.trim(); + if (!value) { + return; + } + params.set("user", value); + window.location.search = params.toString(); + }); +} + +async function boot() { + if (!uuid) { + buildCreateScreen(); + return; + } + if (!isValidUuid(uuid)) { + mount.innerHTML = `

    Invalid UUID in URL.

    `; + return; + } + try { + await loadAndRender(); + } catch (error) { + mount.innerHTML = `

    ${escapeHtml(error.message)}

    `; + } +} + +boot(); diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f68575a --- /dev/null +++ b/bun.lock @@ -0,0 +1,178 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "movie-select", + "dependencies": { + "mysql2": "^3.11.3", + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.0", + "@types/bun": "latest", + }, + }, + }, + "packages": { + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + + "@tailwindcss/cli": ["@tailwindcss/cli@4.2.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.1" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="], + + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mysql2": ["mysql2@3.18.1", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-Mz3yJ+YqppZUFr6Q9tP0g9v3RTWGfLQ/J4RlnQ6CNrWz436+FDtFDICecsbWYwjupgfPpj8ZtNVMsCX79VKpLg=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sql-escaper": ["sql-escaper@1.3.3", "", {}, "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw=="], + + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + } +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..e75577f --- /dev/null +++ b/deploy.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="serve" +REMOTE_APP_DIR="/home/serve/services/movies" +REMOTE_PORT=3001 + +echo "==> Installing local dependencies..." +bun install + +echo "==> Building client bundle + CSS + assets..." +bun run build + +echo "==> Syncing to ${REMOTE_HOST}:${REMOTE_APP_DIR} ..." +rsync -avz --delete \ + --exclude='.DS_Store' \ + --exclude='.git/' \ + --exclude='AI_AGENT_REPORT.md' \ + --exclude='LEARNINGS.md' \ + --exclude='tests/' \ + --exclude='.env' \ + --exclude='.env.example' \ + --exclude='deploy.sh' \ + --exclude='scripts/' \ + --exclude='memory/' \ + --exclude='setup-db.sql' \ + --exclude='data/' \ + --exclude='/index.html' \ + --exclude='/index.php' \ + --exclude='/api.php' \ + --exclude='/styles.css' \ + --exclude='/app.js' \ + --exclude='/algorithm.js' \ + --exclude='/round-state.js' \ + --exclude='/tsconfig.json' \ + ./ "${REMOTE_HOST}:${REMOTE_APP_DIR}/" + +echo "==> Installing server dependencies on remote..." +ssh "${REMOTE_HOST}" "cd ${REMOTE_APP_DIR} && bun install --production" + +echo "==> Restarting service..." +ssh "${REMOTE_HOST}" "systemctl --user restart movie-select 2>/dev/null || true" + +echo "Done. Live at https://serve.uber.space/movies/" diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..2e3de9a --- /dev/null +++ b/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..45e7271 --- /dev/null +++ b/index.html @@ -0,0 +1,57 @@ + + + + + + + + + Movie Select + + +
    +
    +

    Movie Select

    + + +
    + +
    +

    Setup

    +
    + + +
    +
      +
      + + +
      +
        +

        Add at least 2 people and 2 movies.

        +
        + + + + +
        + + + diff --git a/index.php b/index.php new file mode 100644 index 0000000..7d0fdf9 --- /dev/null +++ b/index.php @@ -0,0 +1,23 @@ + + + + + + + + + Movie Select + + +
        +
        +

        Movie Select

        +
        +
        +
        + + + diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..9056e2d --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Movie Select", + "short_name": "MovieSelect", + "start_url": "/movies/", + "scope": "/movies/", + "display": "standalone", + "background_color": "#f3f5f7", + "theme_color": "#0b0f14", + "icons": [ + { + "src": "/movies/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..fa27325 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +bun = "1.3.0" diff --git a/package.json b/package.json new file mode 100644 index 0000000..4794666 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "movie-select", + "version": "2026.02.26", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run src/server/index.ts", + "build:client": "bun build src/client/main.ts --outfile dist/main.js --minify --sourcemap=none", + "build:css": "tailwindcss -i src/client/input.css -o dist/styles.css --minify", + "build:assets": "bun run scripts/copy-assets.ts", + "build": "bun run build:client && bun run build:css && bun run build:assets", + "test": "bun test" + }, + "dependencies": { + "mysql2": "^3.11.3" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.0", + "@types/bun": "latest" + } +} diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..fb00fdd --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Movie Select", + "short_name": "MovieSelect", + "start_url": "/movies/", + "scope": "/movies/", + "display": "standalone", + "background_color": "#f8fafc", + "theme_color": "#0b0f14", + "icons": [ + { + "src": "/movies/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/round-state.js b/round-state.js new file mode 100644 index 0000000..51ad354 --- /dev/null +++ b/round-state.js @@ -0,0 +1,35 @@ +export function allUsersDone(users, doneUsers) { + if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers)) { + return false; + } + return users.every((name) => doneUsers.includes(name)); +} + +export function hasCompleteRatings(movieTitles, votesForUser) { + if (!Array.isArray(movieTitles) || !votesForUser || typeof votesForUser !== "object") { + return false; + } + for (const title of movieTitles) { + if (!Number.isInteger(votesForUser[title])) { + return false; + } + } + return true; +} + +export function collectCompleteRatings(users, movieTitles, votes) { + const output = {}; + if (!Array.isArray(users) || !Array.isArray(movieTitles) || !votes || typeof votes !== "object") { + return output; + } + for (const name of users) { + if (!hasCompleteRatings(movieTitles, votes[name])) { + continue; + } + output[name] = {}; + for (const title of movieTitles) { + output[name][title] = votes[name][title]; + } + } + return output; +} diff --git a/scripts/copy-assets.ts b/scripts/copy-assets.ts new file mode 100644 index 0000000..f96890e --- /dev/null +++ b/scripts/copy-assets.ts @@ -0,0 +1,6 @@ +import { copyFileSync, mkdirSync } from "fs"; + +mkdirSync("dist", { recursive: true }); +copyFileSync("public/manifest.webmanifest", "dist/manifest.webmanifest"); +copyFileSync("icon.svg", "dist/icon.svg"); +console.log("Assets copied."); diff --git a/setup-db.sql b/setup-db.sql new file mode 100644 index 0000000..c796a4f --- /dev/null +++ b/setup-db.sql @@ -0,0 +1,48 @@ +-- Movie Select schema +-- Run once: mysql -u serve -p serve < setup-db.sql + +CREATE TABLE IF NOT EXISTS rounds ( + uuid CHAR(36) PRIMARY KEY, + phase TINYINT NOT NULL DEFAULT 1, + setup_done TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NOT NULL DEFAULT NOW() ON UPDATE NOW() +); + +CREATE TABLE IF NOT EXISTS round_users ( + round_uuid CHAR(36) NOT NULL, + name VARCHAR(30) NOT NULL, + done_phase1 TINYINT(1) NOT NULL DEFAULT 0, + done_phase2 TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (round_uuid, name), + FOREIGN KEY (round_uuid) REFERENCES rounds(uuid) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS movies ( + id INT AUTO_INCREMENT PRIMARY KEY, + round_uuid CHAR(36) NOT NULL, + title VARCHAR(60) NOT NULL, + added_by VARCHAR(30) NOT NULL, + added_at DATETIME NOT NULL DEFAULT NOW(), + UNIQUE KEY uq_round_title (round_uuid, title), + FOREIGN KEY (round_uuid) REFERENCES rounds(uuid) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS votes ( + round_uuid CHAR(36) NOT NULL, + user_name VARCHAR(30) NOT NULL, + movie_title VARCHAR(60) NOT NULL, + rating TINYINT NOT NULL, + PRIMARY KEY (round_uuid, user_name, movie_title), + FOREIGN KEY (round_uuid) REFERENCES rounds(uuid) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS round_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + round_uuid CHAR(36) NOT NULL, + winner VARCHAR(60), + movies_json TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT NOW(), + FOREIGN KEY (round_uuid) REFERENCES rounds(uuid) ON DELETE CASCADE +); diff --git a/setup-server.sh b/setup-server.sh new file mode 100755 index 0000000..3e4b908 --- /dev/null +++ b/setup-server.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Run once to set up the server. +# Usage: bash setup-server.sh +set -euo pipefail + +REMOTE_HOST="serve" +REMOTE_APP_DIR="/home/serve/services/movies" +REMOTE_PORT=3001 +DB_PASS="09a7d97c99aff8fd883d3bbe3da927e254ac507b" + +echo "==> Creating app directory on server..." +ssh "${REMOTE_HOST}" "mkdir -p ${REMOTE_APP_DIR}" + +echo "==> Creating .env on server..." +ssh "${REMOTE_HOST}" "cat > ${REMOTE_APP_DIR}/.env" < Initialising database schema..." +scp setup-db.sql "${REMOTE_HOST}:/tmp/movie-select-setup.sql" +ssh "${REMOTE_HOST}" "mysql -u serve -p'${DB_PASS}' serve < /tmp/movie-select-setup.sql && rm /tmp/movie-select-setup.sql" + +echo "==> Deploying app files..." +bash deploy.sh + +echo "==> Detecting bun path on server..." +BUN_PATH=$(ssh "${REMOTE_HOST}" 'which bun || echo "$HOME/.bun/bin/bun"') +echo " bun at: ${BUN_PATH}" + +echo "==> Creating systemd service..." +ssh "${REMOTE_HOST}" BUN_PATH="${BUN_PATH}" REMOTE_APP_DIR="${REMOTE_APP_DIR}" bash <<'ENDSSH' +mkdir -p ~/.config/systemd/user +cat > ~/.config/systemd/user/movie-select.service < Configuring web backend..." +ssh "${REMOTE_HOST}" "uberspace web backend add /movies port ${REMOTE_PORT} --remove-prefix 2>/dev/null || uberspace web backend set /movies port ${REMOTE_PORT} --remove-prefix" + +echo "==> Service status:" +ssh "${REMOTE_HOST}" "systemctl --user status movie-select --no-pager" + +echo "" +echo "Done. Live at https://serve.uber.space/movies/" diff --git a/src/client/algorithm.ts b/src/client/algorithm.ts new file mode 100644 index 0000000..477fd49 --- /dev/null +++ b/src/client/algorithm.ts @@ -0,0 +1,79 @@ +export function paretoFilter( + movies: string[], + people: string[], + ratings: Record>, +): string[] { + return movies.filter((movieA) => { + return !movies.some((movieB) => { + if (movieA === movieB) return false; + let allAtLeastAsGood = true; + let strictlyBetter = false; + for (const person of people) { + const a = ratings[person]?.[movieA] ?? 0; + const b = ratings[person]?.[movieB] ?? 0; + if (b < a) { allAtLeastAsGood = false; break; } + if (b > a) strictlyBetter = true; + } + return allAtLeastAsGood && strictlyBetter; + }); + }); +} + +export function nashScore( + movie: string, + people: string[], + ratings: Record>, +): number { + let product = 1; + for (const person of people) { + product *= (ratings[person]?.[movie] ?? 0) + 1; + } + return product; +} + +export function averageScore( + movie: string, + people: string[], + ratings: Record>, +): number { + let total = 0; + for (const person of people) { + total += ratings[person]?.[movie] ?? 0; + } + return total / people.length; +} + +export interface RankedMovie { + movie: string; + nash: number; + avg: number; +} + +export interface DecisionResult { + winner: RankedMovie; + remaining: string[]; + ranking: RankedMovie[]; +} + +export function decideMovie(opts: { + movies: string[]; + people: string[]; + ratings: Record>; +}): DecisionResult { + const { movies, people, ratings } = opts; + if (movies.length < 1 || people.length < 1) { + throw new Error("Need at least one movie and one person"); + } + const remaining = paretoFilter(movies, people, ratings); + const scored: RankedMovie[] = remaining.map((movie) => ({ + movie, + nash: nashScore(movie, people, ratings), + avg: averageScore(movie, people, ratings), + })); + scored.sort((a, b) => { + if (b.nash !== a.nash) return b.nash - a.nash; + if (b.avg !== a.avg) return b.avg - a.avg; + return a.movie.localeCompare(b.movie); + }); + return { winner: scored[0]!, remaining, ranking: scored }; +} diff --git a/src/client/input.css b/src/client/input.css new file mode 100644 index 0000000..f4ce102 --- /dev/null +++ b/src/client/input.css @@ -0,0 +1,161 @@ +@import "tailwindcss"; + +@source "../server/index.ts"; +@source "../client/main.ts"; + +@theme { + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* ── Base ── */ +@layer base { + html { + -webkit-tap-highlight-color: transparent; + } + button, input, select, textarea { + font: inherit; + } +} + +/* ── Components ── */ +@layer components { + /* Buttons β€” 44 px min tap target */ + .btn { + @apply inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 + rounded-xl border border-slate-200 bg-white + text-[15px] font-medium text-slate-800 + whitespace-nowrap select-none + transition-transform duration-100 active:scale-[0.97]; + } + .btn-primary { + @apply bg-blue-500 border-blue-500 text-white; + } + .btn-sm { + @apply min-h-[36px] px-4 py-1.5 text-sm; + } + + /* Inputs β€” 44 px min tap target */ + .field { + @apply min-h-[44px] w-full px-4 py-2.5 + border border-slate-300 rounded-xl bg-white text-base + outline-none placeholder:text-slate-400 + transition-[border-color,box-shadow] duration-150; + } + .field-mono { + @apply min-h-[36px] w-full px-3 py-1.5 + border border-slate-300 rounded-xl bg-white + font-mono text-sm + outline-none + transition-[border-color,box-shadow] duration-150; + } + + /* Chips */ + .chip { + @apply inline-flex items-center rounded-full + bg-slate-100 px-3 py-1.5 + text-[13px] font-medium text-slate-700; + } + .chip-btn { + @apply inline-flex items-center rounded-full border-0 + bg-slate-100 px-3 py-1.5 + text-[13px] font-medium text-slate-700 + cursor-pointer select-none + transition-[background-color,transform] duration-100 + hover:bg-slate-200 active:scale-[0.96]; + } + .chip-user { + @apply inline-flex items-center rounded-full border-2 border-blue-400 + bg-blue-100 px-3 py-1.5 + text-[13px] font-medium text-blue-900 + cursor-pointer select-none + transition-[background-color,transform] duration-100 + hover:bg-blue-200 active:scale-[0.96]; + } + + /* Cards */ + .card-inset { + @apply mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4; + } + .card-progress { + @apply mt-3 rounded-2xl border border-blue-200 bg-blue-50 p-4; + } + + /* Layout helpers */ + .chips-list { + @apply mt-3 flex flex-wrap gap-2 list-none p-0 m-0; + } + .links-list { + @apply mt-3 flex flex-col gap-2 list-none p-0 m-0; + } + .input-row { + @apply mt-4 flex gap-2; + } + .action-row { + @apply mt-5 flex flex-wrap justify-end gap-2.5; + } + + /* Typography */ + .screen-title { + @apply m-0 text-xl font-bold tracking-tight text-slate-900; + } + .section-title { + @apply m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400; + } + .hint { + @apply mt-2 text-sm leading-relaxed text-slate-500; + } + .error-msg { + @apply mt-3 text-sm font-medium text-red-500; + } +} + +/* Focus states (outside @layer so they can reference component classes) */ +.field:focus, +.field-mono:focus { + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); +} + +/* Disabled button state */ +.btn:disabled { + opacity: 0.4; + cursor: default; +} +.btn:disabled:active { + transform: none; +} + +/* Safe-area bottom padding for notched phones */ +.safe-b { + padding-bottom: max(2.5rem, env(safe-area-inset-bottom)); +} + +/* ── Range slider ── */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + background: #dbeafe; + border-radius: 9999px; + cursor: pointer; + border: 0; + padding: 0; + outline: none; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 26px; + height: 26px; + border-radius: 50%; + background: #3b82f6; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); +} +input[type="range"]::-moz-range-thumb { + width: 26px; + height: 26px; + border-radius: 50%; + background: #3b82f6; + border: none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); +} diff --git a/src/client/main.ts b/src/client/main.ts new file mode 100644 index 0000000..3329417 --- /dev/null +++ b/src/client/main.ts @@ -0,0 +1,551 @@ +import { decideMovie } from "./algorithm.ts"; +import { collectCompleteRatings } from "./round-state.ts"; + +interface Movie { + title: string; + addedBy: string; + addedAt: string; +} + +interface HistoryEntry { + movies: Movie[]; + winner: string | null; +} + +interface State { + uuid: string; + phase: 1 | 2 | 3; + setupDone: boolean; + users: string[]; + doneUsersPhase1: string[]; + doneUsersPhase2: string[]; + movies: Movie[]; + votes: Record>; + history: HistoryEntry[]; + createdAt: string; + updatedAt: string; +} + +const mount = document.getElementById("app")!; +const path = window.location.pathname.replace(/^\/movies\/?/, ""); +const segments = path.split("/").filter(Boolean); +const uuid = segments[0] ?? ""; +const isAdmin = segments[1] === "admin"; +const params = new URLSearchParams(window.location.search); +let user = (params.get("user") ?? "").trim(); +let state: State | null = null; +let draftRatings: Record = {}; +let adminUserDraft = ""; +let busy = false; + +function isValidUuid(v: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v); +} + +function apiUrl(action: string): string { + return `/movies/api?action=${encodeURIComponent(action)}&uuid=${encodeURIComponent(uuid)}`; +} + +async function apiGetState(): Promise<{ ok: boolean; state: State }> { + const res = await fetch(apiUrl("state")); + return res.json() as Promise<{ ok: boolean; state: State }>; +} + +async function apiPost(action: string, body: Record): Promise<{ ok: boolean; state: State }> { + const res = await fetch(apiUrl(action), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, action, uuid }), + }); + const data = await res.json() as { ok: boolean; state: State; error?: string }; + if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed"); + return data; +} + +function esc(text: string): string { + return text.replace(/[&<>"']/g, (c) => ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c, + ); +} + +function showError(message: string): void { + const el = mount.querySelector("#error"); + if (!el) return; + el.hidden = false; + el.textContent = message; +} + +function doneForPhase(phase: 1 | 2): string[] { + if (!state) return []; + return phase === 1 ? (state.doneUsersPhase1 ?? []) : (state.doneUsersPhase2 ?? []); +} + +function isUserDone(phase: 1 | 2, name: string): boolean { + return !!name && doneForPhase(phase).includes(name); +} + +type RoundResult = + | { kind: "no_movies" } + | { kind: "no_votes" } + | { kind: "ok"; voterCount: number; totalUsers: number; result: ReturnType }; + +function computeRoundResult(): RoundResult { + if (!state) return { kind: "no_movies" }; + const movies = state.movies.map((m) => m.title); + if (movies.length === 0) return { kind: "no_movies" }; + const ratings = collectCompleteRatings(state.users, movies, state.votes ?? {}); + const voters = Object.keys(ratings); + if (voters.length === 0) return { kind: "no_votes" }; + return { + kind: "ok", + voterCount: voters.length, + totalUsers: state.users.length, + result: decideMovie({ movies, people: voters, ratings }), + }; +} + +function resultSection(title: string): string { + const info = computeRoundResult(); + if (info.kind === "no_movies") { + return `

        ${esc(title)}

        No movies were added.

        `; + } + if (info.kind === "no_votes") { + return `

        ${esc(title)}

        No complete votes yet.

        `; + } + const { result, voterCount, totalUsers } = info; + const rows = result.ranking.map((r, i) => ` +
        + ${i + 1} + ${esc(r.movie)} + Nash ${r.nash.toFixed(1)} +
        `).join(""); + return `
        +

        ${esc(title)}

        +

        πŸ† ${esc(result.winner.movie)}

        +

        Voters: ${voterCount} of ${totalUsers}

        +
        ${rows}
        +
        `; +} + +function progressCard(phaseTitle: string, done: number, total: number): string { + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + return `
        +

        ${esc(phaseTitle)}

        +
        +
        +
        +
        + ${done}/${total} +
        +
        `; +} + +// ─── Screens ─────────────────────────────────────────────────────────────── + +function buildCreateScreen(): void { + mount.innerHTML = ` +

        Movie Select

        +

        Start a new round, add everyone, share links β€” pick a film.

        +
        + +
        `; + mount.querySelector("#createRound")!.addEventListener("click", () => { + window.location.href = `/movies/${crypto.randomUUID()}/admin`; + }); +} + +function renderAdminSetup(): void { + mount.innerHTML = ` +

        Add People

        +

        Add everyone who'll vote, then tap Next.

        +
        + + +
        +
          +
          + +
          +`; + + const inp = mount.querySelector("#userInput")!; + inp.value = adminUserDraft; + inp.focus(); + inp.addEventListener("input", () => { adminUserDraft = inp.value; }); + + mount.querySelector("#userList")!.innerHTML = + (state?.users ?? []).map((n) => `
        • ${esc(n)}
        • `).join(""); + + const addUser = async () => { + const name = adminUserDraft.trim(); + if (!name || busy) return; + try { + busy = true; + await apiPost("add_user", { user: name }); + adminUserDraft = ""; + await loadAndRender(); + } catch (e) { showError((e as Error).message); } + finally { busy = false; } + }; + + mount.querySelector("#addUser")!.addEventListener("click", addUser); + inp.addEventListener("keydown", async (e) => { if (e.key === "Enter") { e.preventDefault(); await addUser(); } }); + mount.querySelector("#nextSetup")!.addEventListener("click", async () => { + if (busy) return; + try { + busy = true; + await apiPost("finish_setup", {}); + await loadAndRender(); + } catch (e) { showError((e as Error).message); } + finally { busy = false; } + }); +} + +function renderAdminStatus(): void { + const s = state!; + const d1 = s.doneUsersPhase1 ?? []; + const d2 = s.doneUsersPhase2 ?? []; + const totalSteps = s.users.length * 2; + const completedSteps = d1.length + d2.length; + const base = window.location.origin; + const phaseTitle = s.phase === 1 ? "Phase 1 Β· Movie Collection" : s.phase === 2 ? "Phase 2 Β· Voting" : "Phase 3 Β· Results"; + + const shareRows = s.users.map((name) => { + const link = `${base}/movies/${s.uuid}?user=${encodeURIComponent(name)}`; + const steps = (d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0); + const badge = steps === 2 + ? `Done` + : steps === 1 + ? `1/2` + : `0/2`; + return `
        • +
          + ${esc(name)}${badge} +
          +
          + + +
          +
        • `; + }).join(""); + + mount.innerHTML = ` +

          Admin

          +${progressCard(phaseTitle, completedSteps, totalSteps)} +

          Invite Links

          + +
          + +
          +${s.phase === 3 ? resultSection("Round Result") : ""} +${s.phase === 3 ? `
          ` : ""} +`; + + mount.querySelectorAll(".copyLink").forEach((btn) => { + btn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(btn.dataset["link"] ?? ""); + btn.textContent = "βœ“ Copied"; + setTimeout(() => { btn.textContent = "Copy"; }, 1400); + } catch { showError("Clipboard unavailable"); } + }); + }); + + mount.querySelector("#refreshAdmin")!.addEventListener("click", async () => { + try { await loadAndRender(); } catch (e) { showError((e as Error).message); } + }); + + if (s.phase === 3) { + mount.querySelector("#newRound")!.addEventListener("click", async () => { + if (busy) return; + try { + busy = true; + const info = computeRoundResult(); + const winner = info.kind === "ok" ? info.result.winner.movie : ""; + await apiPost("new_round", { winner }); + await loadAndRender(); + } catch (e) { showError((e as Error).message); } + finally { busy = false; } + }); + } +} + +function renderWaiting(phase: 1 | 2): void { + const s = state!; + const d1 = s.doneUsersPhase1 ?? []; + const d2 = s.doneUsersPhase2 ?? []; + const total = s.users.length * 2; + const done = d1.length + d2.length; + const phaseTitle = phase === 1 ? "Phase 1 Β· Movie Collection" : "Phase 2 Β· Voting"; + const waitMsg = phase === 1 ? "Your movies are in. Waiting for others…" : "Your votes are saved. Waiting for others…"; + + mount.innerHTML = ` +

          Waiting…

          +${progressCard(phaseTitle, done, total)} +

          ${esc(waitMsg)}

          +
          + +
          +`; + mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender()); +} + +function prevMoviesSection(remaining: number): string { + const history = state?.history ?? []; + if (history.length === 0) return ""; + + const currentTitles = new Set((state?.movies ?? []).map((m) => m.title.replace(/^πŸ†\s*/, "").toLowerCase())); + const wonMovies: string[] = []; + const regularMovies = new Set(); + + for (let i = history.length - 1; i >= 0; i--) { + const round = history[i]!; + if (round.winner) wonMovies.push(round.winner); + for (const m of round.movies ?? []) regularMovies.add(m.title.replace(/^πŸ†\s*/, "")); + } + + const wonSet = new Set(wonMovies.map((t) => t.replace(/^πŸ†\s*/, "").toLowerCase())); + const regularList = [...regularMovies] + .filter((t) => !wonSet.has(t.toLowerCase()) && !currentTitles.has(t.toLowerCase())) + .sort((a, b) => a.localeCompare(b)); + + const seenWon = new Set(); + const wonList: Array<{ display: string; value: string }> = []; + for (const w of wonMovies) { + const clean = w.replace(/^πŸ†\s*/, ""); + const key = clean.toLowerCase(); + if (!clean || seenWon.has(key) || currentTitles.has(key)) continue; + seenWon.add(key); + wonList.push({ display: `πŸ† ${clean}`, value: clean }); + } + + const items = [...regularList.map((t) => ({ display: t, value: t })), ...wonList]; + if (items.length === 0) return ""; + + const chips = items.map(({ display, value }) => { + const off = remaining <= 0; + return `
        • `; + }).join(""); + + return `

          Add from History

            ${chips}
          `; +} + +function renderMoviePhase(): void { + const s = state!; + const canAdd = !!user && s.users.includes(user); + const myCount = canAdd ? s.movies.filter((m) => m.addedBy.toLowerCase() === user.toLowerCase()).length : 0; + const remaining = Math.max(0, 5 - myCount); + if (isUserDone(1, user)) { renderWaiting(1); return; } + + mount.innerHTML = ` +

          Add Movies

          +

          Phase 1 of 3 Β· Up to 5 movies each, then tap Done.

          +${canAdd ? `

          ${remaining} slot${remaining === 1 ? "" : "s"} left

          ` : `

          Open your personal link to add movies.

          `} +${canAdd ? `
          + + +
          ` : ""} +${canAdd && myCount > 0 ? `

          Your Picks (tap to remove)

          ` : ""} +
            +${canAdd ? prevMoviesSection(remaining) : ""} +${canAdd ? `
            + + +
            ` : ""} +`; + + mount.querySelector("#movieList")!.innerHTML = s.movies + .filter((m) => canAdd && m.addedBy.toLowerCase() === user.toLowerCase()) + .map((m) => { + const clean = m.title.replace(/^πŸ†\s*/, ""); + return `
          • `; + }).join(""); + + if (!canAdd) return; + + const addMovie = async () => { + const inp = mount.querySelector("#movieInput")!; + const title = inp.value.trim(); + if (!title || busy) return; + try { + busy = true; + await apiPost("add_movie", { user, title }); + inp.value = ""; + await loadAndRender(); + } catch (e) { showError((e as Error).message); } + finally { busy = false; } + }; + + const inp = mount.querySelector("#movieInput")!; + inp.focus(); + mount.querySelector("#addMovie")!.addEventListener("click", addMovie); + inp.addEventListener("keydown", async (e) => { if (e.key === "Enter") { e.preventDefault(); await addMovie(); } }); + + mount.querySelectorAll(".chip-btn[data-title]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (busy || btn.disabled) return; + try { busy = true; await apiPost("add_movie", { user, title: btn.dataset["title"] }); await loadAndRender(); } + catch (e) { showError((e as Error).message); } + finally { busy = false; } + }); + }); + + mount.querySelectorAll(".chip-user").forEach((btn) => { + btn.addEventListener("click", async () => { + if (busy) return; + try { busy = true; await apiPost("remove_movie", { user, title: btn.dataset["title"] }); await loadAndRender(); } + catch (e) { showError((e as Error).message); } + finally { busy = false; } + }); + }); + + mount.querySelector("#markDone")!.addEventListener("click", async () => { + if (busy) return; + try { busy = true; await apiPost("mark_done", { user, phase: 1 }); await loadAndRender(); } + catch (e) { showError((e as Error).message); } + finally { busy = false; } + }); + + mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender()); +} + +function renderVotingPhase(): void { + const s = state!; + const movies = s.movies.map((m) => m.title); + if (!s.votes[user]) s.votes[user] = {}; + if (isUserDone(2, user)) { renderWaiting(2); return; } + + const cards = movies.map((title) => { + const current = draftRatings[title] ?? s.votes[user]?.[title] ?? 3; + return `
            +

            ${esc(title)}

            +
            + + ${current} +
            +
            + SkipMehOKGoodGreatLove +
            +
            `; + }).join(""); + + mount.innerHTML = ` +

            Rate Movies

            +

            Phase 2 of 3 Β· Rate each film 0–5, then tap Done.

            +
            ${cards}
            +
            + + + +
            +`; + + mount.querySelectorAll(".rating").forEach((inp) => { + inp.addEventListener("input", () => { + const title = inp.dataset["title"] ?? ""; + const rating = Math.max(0, Math.min(5, parseInt(inp.value || "0", 10))); + inp.value = String(rating); + draftRatings[title] = rating; + const valEl = inp.closest("div.flex")?.querySelector(".rating-value"); + if (valEl) valEl.textContent = String(rating); + }); + }); + + const collectRatings = (): Record => { + const out: Record = {}; + mount.querySelectorAll(".rating").forEach((inp) => { + out[inp.dataset["title"] ?? ""] = Math.max(0, Math.min(5, parseInt(inp.value || "0", 10))); + }); + return out; + }; + + mount.querySelector("#saveVotes")!.addEventListener("click", async () => { + if (busy) return; + try { busy = true; await apiPost("vote_many", { user, ratings: collectRatings() }); draftRatings = {}; await loadAndRender(); } + catch (e) { showError((e as Error).message); } + finally { busy = false; } + }); + + mount.querySelector("#doneVoting")!.addEventListener("click", async () => { + if (busy) return; + try { + busy = true; + await apiPost("vote_many", { user, ratings: collectRatings() }); + await apiPost("mark_done", { user, phase: 2 }); + draftRatings = {}; + await loadAndRender(); + } catch (e) { showError((e as Error).message); } + finally { busy = false; } + }); + + mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender()); +} + +function renderFinalPage(): void { + mount.innerHTML = ` +

            Result

            +

            Phase 3 of 3 Β· Voting complete.

            +${resultSection("Round Outcome")} +
            + +
            +`; + mount.querySelector("#refreshUser")!.addEventListener("click", () => loadAndRender()); +} + +function askUserName(): void { + mount.innerHTML = ` +

            Who are you?

            +

            Enter the name from your invite link.

            +
            + + +
            `; + const inp = mount.querySelector("#nameInput")!; + inp.focus(); + const submit = () => { + const v = inp.value.trim(); + if (!v) return; + params.set("user", v); + window.location.search = params.toString(); + }; + mount.querySelector("#saveName")!.addEventListener("click", submit); + inp.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); +} + +async function loadAndRender(): Promise { + const data = await apiGetState(); + state = data.state; + if (!state.setupDone && isAdmin) { renderAdminSetup(); return; } + if (!state.setupDone) { + mount.innerHTML = `

            Admin is still setting up. Open your link when ready.

            +
            `; + mount.querySelector("#r")!.addEventListener("click", () => loadAndRender()); + return; + } + if (isAdmin) { renderAdminStatus(); return; } + if (!user) { askUserName(); return; } + if (state.users.length > 0 && !state.users.includes(user)) { + mount.innerHTML = `

            Unknown user β€” use your invite link.

            `; + return; + } + if (state.phase === 1) { renderMoviePhase(); return; } + if (state.phase === 2) { renderVotingPhase(); return; } + renderFinalPage(); +} + +async function boot(): Promise { + if (!uuid) { buildCreateScreen(); return; } + if (!isValidUuid(uuid)) { + mount.innerHTML = `

            Invalid session URL.

            `; + return; + } + try { await loadAndRender(); } + catch (e) { mount.innerHTML = `

            ${esc((e as Error).message)}

            `; } +} + +boot(); diff --git a/src/client/round-state.ts b/src/client/round-state.ts new file mode 100644 index 0000000..8bf3d57 --- /dev/null +++ b/src/client/round-state.ts @@ -0,0 +1,34 @@ +export function allUsersDone(users: string[], doneUsers: string[]): boolean { + if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers)) return false; + return users.every((name) => doneUsers.includes(name)); +} + +export function hasCompleteRatings( + movieTitles: string[], + votesForUser: Record | null | undefined, +): boolean { + if (!Array.isArray(movieTitles) || !votesForUser || typeof votesForUser !== "object") return false; + for (const title of movieTitles) { + if (!Number.isInteger(votesForUser[title])) return false; + } + return true; +} + +export function collectCompleteRatings( + users: string[], + movieTitles: string[], + votes: Record>, +): Record> { + const output: Record> = {}; + if (!Array.isArray(users) || !Array.isArray(movieTitles) || !votes || typeof votes !== "object") { + return output; + } + for (const name of users) { + if (!hasCompleteRatings(movieTitles, votes[name])) continue; + output[name] = {}; + for (const title of movieTitles) { + output[name][title] = votes[name]![title]!; + } + } + return output; +} diff --git a/src/server/api.ts b/src/server/api.ts new file mode 100644 index 0000000..167f18f --- /dev/null +++ b/src/server/api.ts @@ -0,0 +1,197 @@ +import { + loadState, + addUserInDb, + setSetupDoneInDb, + addMovieInDb, + removeMovieInDb, + upsertVoteInDb, + setUserDoneInDb, + setPhaseInDb, + resetDoneUsersInDb, + clearMoviesAndVotesInDb, + addHistoryEntryInDb, + resetUserDoneForUser, +} from "./db.ts"; +import type { State } from "./types.ts"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const TROPHY_RE = /^(?:\u{1F3C6}\s*)+/u; + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + } +} + +function fail(status: number, message: string): never { + throw new ApiError(status, message); +} + +function cleanUser(value: string): string { + const trimmed = value.trim(); + if (trimmed === "" || trimmed.length > 30) fail(400, "Invalid user"); + return trimmed; +} + +function cleanMovie(value: string): string { + let trimmed = value.trim().replace(TROPHY_RE, ""); + if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title"); + return trimmed; +} + +function allUsersDone(users: string[], doneUsers: string[]): boolean { + if (users.length === 0) return false; + return users.every((name) => doneUsers.includes(name)); +} + +export async function handleAction( + action: string, + uuid: string, + body: Record, +): Promise { + if (!UUID_RE.test(uuid)) fail(400, "Invalid UUID"); + + if (action === "state") { + return await loadState(uuid); + } + + // All mutating actions below + const state = await loadState(uuid); + + if (action === "add_user") { + if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed"); + const user = cleanUser(String(body["user"] ?? "")); + if (!state.users.includes(user)) { + await addUserInDb(uuid, user, state.users.length); + } + return await loadState(uuid); + } + + if (action === "finish_setup") { + if (state.users.length < 1) fail(409, "Add at least one user first"); + await setSetupDoneInDb(uuid); + await resetDoneUsersInDb(uuid, "both"); + return await loadState(uuid); + } + + if (action === "add_movie") { + if (!state.setupDone) fail(409, "Setup is not finished"); + if (state.phase !== 1) fail(409, "Movie phase is closed"); + const user = cleanUser(String(body["user"] ?? "")); + if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user"); + const title = cleanMovie(String(body["title"] ?? "")); + const myCount = state.movies.filter( + (m) => m.addedBy.toLowerCase() === user.toLowerCase(), + ).length; + if (myCount >= 5) fail(409, "Movie limit reached for this user (5)"); + const duplicate = state.movies.some( + (m) => m.title.toLowerCase() === title.toLowerCase(), + ); + if (duplicate) fail(409, "Movie already exists"); + await addMovieInDb(uuid, title, user); + // Un-mark done for this user when they add a movie + await resetUserDoneForUser(uuid, user, 1); + return await loadState(uuid); + } + + if (action === "remove_movie") { + if (!state.setupDone) fail(409, "Setup is not finished"); + if (state.phase !== 1) fail(409, "Movie phase is closed"); + const user = cleanUser(String(body["user"] ?? "")); + if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user"); + const title = cleanMovie(String(body["title"] ?? "")); + await removeMovieInDb(uuid, title, user); + await resetUserDoneForUser(uuid, user, 1); + return await loadState(uuid); + } + + if (action === "vote_many") { + if (state.phase !== 2) fail(409, "Voting phase is not active"); + const user = cleanUser(String(body["user"] ?? "")); + if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user"); + const ratings = body["ratings"]; + if (!ratings || typeof ratings !== "object" || Array.isArray(ratings)) { + fail(400, "Ratings payload missing"); + } + const ratingsMap = ratings as Record; + for (const movie of state.movies) { + const raw = ratingsMap[movie.title]; + let rating: number; + if (typeof raw === "number" && Number.isInteger(raw)) { + rating = raw; + } else if (typeof raw === "string" && /^\d+$/.test(raw)) { + rating = parseInt(raw, 10); + } else { + fail(400, "Each movie needs a rating"); + } + if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5"); + await upsertVoteInDb(uuid, user, movie.title, rating); + } + // Un-mark done for this user when they update votes + await resetUserDoneForUser(uuid, user, 2); + return await loadState(uuid); + } + + if (action === "mark_done") { + if (!state.setupDone) fail(409, "Setup is not finished"); + const user = cleanUser(String(body["user"] ?? "")); + if (state.users.length > 0 && !state.users.includes(user)) fail(403, "Unknown user"); + const phase = Number(body["phase"] ?? state.phase); + + if (phase === 1) { + if (state.phase !== 1) fail(409, "Movie phase is not active"); + await setUserDoneInDb(uuid, user, 1); + const updated = await loadState(uuid); + if (allUsersDone(updated.users, updated.doneUsersPhase1)) { + await setPhaseInDb(uuid, 2); + await resetDoneUsersInDb(uuid, 2); + } + return await loadState(uuid); + } + + if (phase === 2) { + if (state.phase !== 2) fail(409, "Voting phase is not active"); + // Verify all movies rated + const movieTitles = state.movies.map((m) => m.title); + const userVotes = state.votes[user] ?? {}; + for (const title of movieTitles) { + const v = userVotes[title]; + if (typeof v !== "number") fail(409, "Rate every movie before finishing"); + } + await setUserDoneInDb(uuid, user, 2); + const updated = await loadState(uuid); + if (allUsersDone(updated.users, updated.doneUsersPhase2)) { + await setPhaseInDb(uuid, 3); + } + return await loadState(uuid); + } + + fail(400, "Invalid done phase"); + } + + if (action === "set_phase") { + if (!state.setupDone) fail(409, "Finish setup first"); + const phase = Number(body["phase"] ?? 0); + if (phase !== 1 && phase !== 2 && phase !== 3) fail(400, "Invalid phase"); + await setPhaseInDb(uuid, phase); + if (phase === 1) await resetDoneUsersInDb(uuid, "both"); + if (phase === 2) await resetDoneUsersInDb(uuid, 2); + return await loadState(uuid); + } + + if (action === "new_round") { + if (state.phase !== 3) fail(409, "Round is not finished yet"); + let winner = String(body["winner"] ?? "").trim().replace(TROPHY_RE, ""); + if (winner === "") winner = ""; + await addHistoryEntryInDb(uuid, winner || null, state.movies); + await clearMoviesAndVotesInDb(uuid); + await setPhaseInDb(uuid, 1); + await resetDoneUsersInDb(uuid, "both"); + return await loadState(uuid); + } + + fail(404, "Unknown action"); +} diff --git a/src/server/db.ts b/src/server/db.ts new file mode 100644 index 0000000..e504d93 --- /dev/null +++ b/src/server/db.ts @@ -0,0 +1,182 @@ +import mysql from "mysql2/promise"; +import type { RowDataPacket } from "mysql2/promise"; +import type { State, Movie } from "./types.ts"; + +export const pool = mysql.createPool({ + host: process.env["DB_HOST"] ?? "127.0.0.1", + port: Number(process.env["DB_PORT"] ?? 3306), + user: process.env["DB_USER"] ?? "serve", + password: process.env["DB_PASS"] ?? "", + database: process.env["DB_NAME"] ?? "serve", + waitForConnections: true, + connectionLimit: 10, + decimalNumbers: true, + timezone: "+00:00", +}); + +function fmtDate(d: unknown): string { + if (d instanceof Date) return d.toISOString(); + if (typeof d === "string") return d; + return new Date().toISOString(); +} + +export async function loadState(uuid: string): Promise { + const conn = await pool.getConnection(); + try { + // Ensure round row exists + await conn.execute( + "INSERT IGNORE INTO rounds (uuid) VALUES (?)", + [uuid], + ); + + const [[round]] = await conn.execute( + "SELECT phase, setup_done, created_at, updated_at FROM rounds WHERE uuid = ?", + [uuid], + ); + + const [userRows] = await conn.execute( + "SELECT name, done_phase1, done_phase2 FROM round_users WHERE round_uuid = ? ORDER BY sort_order, name", + [uuid], + ); + + const [movieRows] = await conn.execute( + "SELECT title, added_by, added_at FROM movies WHERE round_uuid = ? ORDER BY id", + [uuid], + ); + + const [voteRows] = await conn.execute( + "SELECT user_name, movie_title, rating FROM votes WHERE round_uuid = ?", + [uuid], + ); + + const [historyRows] = await conn.execute( + "SELECT winner, movies_json FROM round_history WHERE round_uuid = ? ORDER BY id", + [uuid], + ); + + const users: string[] = userRows.map((u) => String(u["name"])); + const donePhase1 = userRows.filter((u) => u["done_phase1"]).map((u) => String(u["name"])); + const donePhase2 = userRows.filter((u) => u["done_phase2"]).map((u) => String(u["name"])); + + const votes: Record> = {}; + for (const v of voteRows) { + const uname = String(v["user_name"]); + const mtitle = String(v["movie_title"]); + if (!votes[uname]) votes[uname] = {}; + votes[uname][mtitle] = Number(v["rating"]); + } + + const history = historyRows.map((h) => ({ + winner: h["winner"] ? String(h["winner"]) : null, + movies: JSON.parse(String(h["movies_json"])) as Movie[], + })); + + const phase = Number(round?.["phase"] ?? 1); + + return { + uuid, + phase: (phase === 1 || phase === 2 || phase === 3 ? phase : 1) as 1 | 2 | 3, + setupDone: Boolean(round?.["setup_done"]), + users, + doneUsersPhase1: donePhase1, + doneUsersPhase2: donePhase2, + movies: movieRows.map((m) => ({ + title: String(m["title"]), + addedBy: String(m["added_by"]), + addedAt: fmtDate(m["added_at"]), + })), + votes, + history, + createdAt: fmtDate(round?.["created_at"]), + updatedAt: fmtDate(round?.["updated_at"]), + }; + } finally { + conn.release(); + } +} + +export async function setPhaseInDb(uuid: string, phase: number): Promise { + await pool.execute("UPDATE rounds SET phase = ? WHERE uuid = ?", [phase, uuid]); +} + +export async function setSetupDoneInDb(uuid: string): Promise { + await pool.execute("UPDATE rounds SET setup_done = 1 WHERE uuid = ?", [uuid]); +} + +export async function addUserInDb(uuid: string, name: string, sortOrder: number): Promise { + await pool.execute( + "INSERT IGNORE INTO round_users (round_uuid, name, sort_order) VALUES (?, ?, ?)", + [uuid, name, sortOrder], + ); +} + +export async function addMovieInDb(uuid: string, title: string, addedBy: string): Promise { + await pool.execute( + "INSERT INTO movies (round_uuid, title, added_by) VALUES (?, ?, ?)", + [uuid, title, addedBy], + ); +} + +export async function removeMovieInDb(uuid: string, title: string, addedBy: string): Promise { + await pool.execute( + "DELETE FROM movies WHERE round_uuid = ? AND title = ? AND added_by = ?", + [uuid, title, addedBy], + ); +} + +export async function upsertVoteInDb( + uuid: string, + userName: string, + movieTitle: string, + rating: number, +): Promise { + await pool.execute( + "INSERT INTO votes (round_uuid, user_name, movie_title, rating) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE rating = ?", + [uuid, userName, movieTitle, rating, rating], + ); +} + +export async function setUserDoneInDb( + uuid: string, + name: string, + phase: 1 | 2, +): Promise { + const col = phase === 1 ? "done_phase1" : "done_phase2"; + await pool.execute( + `UPDATE round_users SET ${col} = 1 WHERE round_uuid = ? AND name = ?`, + [uuid, name], + ); +} + +export async function resetDoneUsersInDb(uuid: string, phase: 1 | 2 | "both"): Promise { + if (phase === "both" || phase === 1) { + await pool.execute("UPDATE round_users SET done_phase1 = 0 WHERE round_uuid = ?", [uuid]); + } + if (phase === "both" || phase === 2) { + await pool.execute("UPDATE round_users SET done_phase2 = 0 WHERE round_uuid = ?", [uuid]); + } +} + +export async function clearMoviesAndVotesInDb(uuid: string): Promise { + await pool.execute("DELETE FROM movies WHERE round_uuid = ?", [uuid]); + await pool.execute("DELETE FROM votes WHERE round_uuid = ?", [uuid]); +} + +export async function addHistoryEntryInDb( + uuid: string, + winner: string | null, + movies: Movie[], +): Promise { + await pool.execute( + "INSERT INTO round_history (round_uuid, winner, movies_json) VALUES (?, ?, ?)", + [uuid, winner, JSON.stringify(movies)], + ); +} + +export async function resetUserDoneForUser(uuid: string, name: string, phase: 1 | 2): Promise { + const col = phase === 1 ? "done_phase1" : "done_phase2"; + await pool.execute( + `UPDATE round_users SET ${col} = 0 WHERE round_uuid = ? AND name = ?`, + [uuid, name], + ); +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..88bd8b5 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,128 @@ +import { statSync } from "fs"; +import { join } from "path"; +import { handleAction, ApiError } from "./api.ts"; + +const PORT = Number(process.env["PORT"] ?? 3001); +const DIST_DIR = join(import.meta.dir, "../../dist"); + +const MIME: Record = { + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".ico": "image/x-icon", +}; + +function fileVersion(rel: string): number { + try { + return statSync(join(DIST_DIR, rel)).mtimeMs; + } catch { + return 0; + } +} + +function htmlShell(): string { + const jsV = fileVersion("main.js"); + const cssV = fileVersion("styles.css"); + return ` + + + + + + + + + +Movie Select + + +
            +
            +

            Movie Select

            +
            +
            +
            + + +`; +} + +async function serveStatic(path: string): Promise { + const ext = path.substring(path.lastIndexOf(".")); + const contentType = MIME[ext]; + if (!contentType) return null; + const file = Bun.file(join(DIST_DIR, path)); + if (!(await file.exists())) return null; + return new Response(file, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} + +function jsonOk(data: unknown): Response { + return new Response(JSON.stringify(data), { + headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }, + }); +} + +function jsonErr(status: number, message: string): Response { + return new Response(JSON.stringify({ ok: false, error: message }), { + status, + headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }, + }); +} + +const server = Bun.serve({ + port: PORT, + hostname: "::", + async fetch(req) { + const url = new URL(req.url); + const pathname = url.pathname; + const method = req.method; + + // Static assets + if (pathname === "/main.js" || pathname === "/styles.css" || pathname === "/icon.svg" || pathname === "/manifest.webmanifest") { + const resp = await serveStatic(pathname.slice(1)); + if (resp) return resp; + } + + // API + if (pathname === "/api") { + const uuidParam = url.searchParams.get("uuid") ?? ""; + let action = url.searchParams.get("action") ?? "state"; + let body: Record = {}; + + if (method === "POST") { + try { + const raw = await req.text(); + if (raw) body = JSON.parse(raw) as Record; + } catch { + return jsonErr(400, "Invalid JSON body"); + } + action = String(body["action"] ?? action); + } else if (method !== "GET") { + return jsonErr(405, "Use POST for mutations"); + } + + const uuid = uuidParam || String(body["uuid"] ?? ""); + try { + const state = await handleAction(action, uuid, body); + return jsonOk({ ok: true, state }); + } catch (e) { + if (e instanceof ApiError) return jsonErr(e.status, e.message); + console.error(e); + return jsonErr(500, "Internal server error"); + } + } + + // SPA fallback β€” serve HTML shell for all other paths + return new Response(htmlShell(), { + headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, + }); + }, +}); + +console.log(`Movie Select listening on port ${server.port}`); diff --git a/src/server/types.ts b/src/server/types.ts new file mode 100644 index 0000000..91642a7 --- /dev/null +++ b/src/server/types.ts @@ -0,0 +1,24 @@ +export interface Movie { + title: string; + addedBy: string; + addedAt: string; +} + +export interface HistoryEntry { + movies: Movie[]; + winner: string | null; +} + +export interface State { + uuid: string; + phase: 1 | 2 | 3; + setupDone: boolean; + users: string[]; + doneUsersPhase1: string[]; + doneUsersPhase2: string[]; + movies: Movie[]; + votes: Record>; + history: HistoryEntry[]; + createdAt: string; + updatedAt: string; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..3fafd53 --- /dev/null +++ b/styles.css @@ -0,0 +1,296 @@ +:root { + --bg: #f3f5f7; + --card: #ffffff; + --text: #101418; + --muted: #4e5968; + --line: #d4dbe3; + --primary: #0a84ff; + --chip: #eef2f7; + --error: #b00020; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: linear-gradient(180deg, #f9fbfd 0%, var(--bg) 100%); + color: var(--text); +} + +.app { + max-width: 680px; + margin: 0 auto; + padding: 0.75rem; + display: grid; + gap: 1rem; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; +} + +h1, +h2, +h3 { + margin: 0; +} + +h1 { + font-size: 1.5rem; +} + +h2 { + font-size: 1.1rem; +} + +h3 { + margin-top: 1rem; + margin-bottom: 0.6rem; + font-size: 1rem; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 1rem; +} + +.card.inset { + margin-top: 1rem; + background: #fafcff; +} + +.progressCard { + border-color: #9ec8ff; + background: #f7fbff; +} + +.progressTrack { + width: 100%; + height: 0.85rem; + background: #dce9f8; + border-radius: 999px; + overflow: hidden; + margin-top: 0.6rem; +} + +.progressFill { + height: 100%; + background: var(--primary); +} + +.row { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +input, +button { + font: inherit; +} + +input { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.6rem 0.7rem; + background: #fff; +} + +button { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.6rem 0.8rem; + background: #fff; + cursor: pointer; + white-space: nowrap; +} + +button.primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.actions { + display: flex; + gap: 0.6rem; + margin-top: 0.8rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + padding: 0; + margin: 0.75rem 0 0; + list-style: none; +} + +.chips li { + background: var(--chip); + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-size: 0.95rem; +} + +button.chip { + background: var(--chip); + border: none; + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-size: 0.95rem; + cursor: pointer; + line-height: inherit; +} + +button.chip.userMovie { + background: #c7e9ff; + border: 2px solid var(--primary); + font-weight: 500; +} + +button.chip.userMovie:hover { + background: #9ed4ff; +} + +button.chip:hover:not(:disabled) { + background: #dce9f8; +} + +button.chip:disabled { + opacity: 0.45; + cursor: default; +} + +.links { + list-style: none; + padding: 0; + margin: 0.8rem 0 0; + display: grid; + gap: 0.5rem; +} + +.links li { + display: block; + padding: 0.55rem 0.65rem; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; +} + +.shareName { + display: inline-block; + margin-bottom: 0.35rem; +} + +.shareControls { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.shareInput { + flex: 1 1 auto; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.85rem; +} + +.copyLink { + flex: 0 0 auto; +} + +.statusList li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; +} + +.hint { + color: var(--muted); + font-size: 0.93rem; + margin: 0.65rem 0 0; +} + +code { + font-size: 0.9em; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 0.8rem; +} + +th, +td { + padding: 0.55rem; + border-bottom: 1px solid var(--line); + text-align: left; +} + +.rating { + width: 100%; +} + +.ratingRow { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.6rem; + align-items: center; +} + +.ratingValue { + min-width: 1.25rem; + text-align: right; + font-weight: 600; +} + +.winner { + font-weight: 600; + font-size: 1.05rem; +} + +.muted { + color: var(--muted); +} + +.error { + margin-top: 0.8rem; + color: var(--error); + font-weight: 600; +} + +@media (min-width: 720px) { + .app { + max-width: 760px; + padding: 1rem; + } + + .card { + padding: 1rem; + } +} + +@media (max-width: 600px) { + .row { + flex-direction: column; + } + + .shareControls { + flex-direction: column; + align-items: stretch; + } +} diff --git a/tests/algorithm.test.mjs b/tests/algorithm.test.mjs new file mode 100644 index 0000000..954b952 --- /dev/null +++ b/tests/algorithm.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { decideMovie, paretoFilter } from "../algorithm.js"; + +function testParetoFilter() { + const movies = ["A", "B"]; + const people = ["P1", "P2"]; + const ratings = { + P1: { A: 1, B: 3 }, + P2: { A: 2, B: 4 }, + }; + const remaining = paretoFilter(movies, people, ratings); + assert.deepEqual(remaining, ["B"]); +} + +function testNashProtectsAgainstHardNo() { + const movies = ["Consensus", "Polarizing"]; + const people = ["A", "B", "C"]; + const ratings = { + A: { Consensus: 3, Polarizing: 5 }, + B: { Consensus: 3, Polarizing: 5 }, + C: { Consensus: 3, Polarizing: 0 }, + }; + const result = decideMovie({ movies, people, ratings }); + assert.equal(result.winner.movie, "Consensus"); +} + +function testTieBreaker() { + const movies = ["Alpha", "Beta"]; + const people = ["A", "B"]; + const ratings = { + A: { Alpha: 2, Beta: 2 }, + B: { Alpha: 2, Beta: 2 }, + }; + const result = decideMovie({ movies, people, ratings }); + assert.equal(result.winner.movie, "Alpha"); +} + +function run() { + testParetoFilter(); + testNashProtectsAgainstHardNo(); + testTieBreaker(); + console.log("All tests passed"); +} + +run(); diff --git a/tests/algorithm.test.ts b/tests/algorithm.test.ts new file mode 100644 index 0000000..5ce20b1 --- /dev/null +++ b/tests/algorithm.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { decideMovie, paretoFilter } from "../src/client/algorithm.ts"; + +test("paretoFilter removes dominated movies", () => { + const movies = ["A", "B"]; + const people = ["P1", "P2"]; + const ratings = { P1: { A: 1, B: 3 }, P2: { A: 2, B: 4 } }; + expect(paretoFilter(movies, people, ratings)).toEqual(["B"]); +}); + +test("nash protects against hard no", () => { + const movies = ["Consensus", "Polarizing"]; + const people = ["A", "B", "C"]; + const ratings = { + A: { Consensus: 3, Polarizing: 5 }, + B: { Consensus: 3, Polarizing: 5 }, + C: { Consensus: 3, Polarizing: 0 }, + }; + const result = decideMovie({ movies, people, ratings }); + expect(result.winner.movie).toBe("Consensus"); +}); + +test("tie-breaker uses alphabetical order", () => { + const movies = ["Alpha", "Beta"]; + const people = ["A", "B"]; + const ratings = { A: { Alpha: 2, Beta: 2 }, B: { Alpha: 2, Beta: 2 } }; + const result = decideMovie({ movies, people, ratings }); + expect(result.winner.movie).toBe("Alpha"); +}); diff --git a/tests/round-state.test.mjs b/tests/round-state.test.mjs new file mode 100644 index 0000000..52de633 --- /dev/null +++ b/tests/round-state.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { allUsersDone, hasCompleteRatings, collectCompleteRatings } from "../round-state.js"; + +function testAllUsersDone() { + assert.equal(allUsersDone(["A", "B"], ["A", "B"]), true); + assert.equal(allUsersDone(["A", "B"], ["A"]), false); + assert.equal(allUsersDone([], []), false); +} + +function testCompleteRatings() { + assert.equal(hasCompleteRatings(["M1", "M2"], { M1: 2, M2: 5 }), true); + assert.equal(hasCompleteRatings(["M1", "M2"], { M1: 2 }), false); +} + +function testCollectCompleteRatings() { + const users = ["A", "B"]; + const movieTitles = ["M1", "M2"]; + const votes = { + A: { M1: 1, M2: 2 }, + B: { M1: 3 }, + }; + assert.deepEqual(collectCompleteRatings(users, movieTitles, votes), { + A: { M1: 1, M2: 2 }, + }); +} + +function run() { + testAllUsersDone(); + testCompleteRatings(); + testCollectCompleteRatings(); + console.log("Round state tests passed"); +} + +run(); diff --git a/tests/round-state.test.ts b/tests/round-state.test.ts new file mode 100644 index 0000000..becfd92 --- /dev/null +++ b/tests/round-state.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { allUsersDone, hasCompleteRatings, collectCompleteRatings } from "../src/client/round-state.ts"; + +test("allUsersDone returns true when all done", () => { + expect(allUsersDone(["A", "B"], ["A", "B"])).toBe(true); + expect(allUsersDone(["A", "B"], ["A"])).toBe(false); + expect(allUsersDone([], [])).toBe(false); +}); + +test("hasCompleteRatings detects missing ratings", () => { + expect(hasCompleteRatings(["M1", "M2"], { M1: 2, M2: 5 })).toBe(true); + expect(hasCompleteRatings(["M1", "M2"], { M1: 2 })).toBe(false); +}); + +test("collectCompleteRatings filters incomplete voters", () => { + const result = collectCompleteRatings( + ["A", "B"], + ["M1", "M2"], + { A: { M1: 1, M2: 2 }, B: { M1: 3 } }, + ); + expect(result).toEqual({ A: { M1: 1, M2: 2 } }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..661df49 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "types": ["bun-types"] + }, + "include": ["src/**/*", "scripts/**/*", "tests/**/*"] +}