commit 1e65d8930d41794ecbc89138465b761c405f6e74 Author: Felix FΓΆrtsch Date: Sun Mar 1 11:44:21 2026 +0100 sync current state 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/**/*"] +}