From 35fbb1b47dcdef10731301ca5a3bdee1fcd86c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 4 Mar 2026 23:01:11 +0100 Subject: [PATCH] normalize biome config (lineWidth 80, quoteStyle double), switch server to bun native, add CLAUDE.md Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + mise.toml => .mise.toml | 1 - CLAUDE.md | 52 ++++ biome.json | 9 +- bun.lock | 4 +- deploy.sh | 3 + index.html | 2 +- package.json | 3 +- src/client/hooks/use-round-mutation.ts | 62 +++-- src/client/hooks/use-round.ts | 6 +- src/client/hooks/use-sw-update.ts | 46 ++++ src/client/index.css | 6 + src/client/lib/api-client.ts | 24 +- src/client/lib/sessions.ts | 36 +++ src/client/lib/utils.ts | 6 +- src/client/main.tsx | 10 +- src/client/router.tsx | 12 +- src/client/routes/$uuid/admin.tsx | 231 ++++++++++++------ src/client/routes/$uuid/home.tsx | 99 ++++++++ src/client/routes/$uuid/route.tsx | 241 ++++++++++--------- src/client/routes/__root.tsx | 101 +++++++- src/client/routes/index.tsx | 12 +- src/server/app.ts | 24 +- src/server/features/rounds/router.ts | 104 +++++--- src/server/features/rounds/schema.ts | 26 +- src/server/features/rounds/service.ts | 214 ++++++++-------- src/server/index.ts | 14 +- src/server/shared/db/index.ts | 16 +- src/server/shared/db/schema/index.ts | 10 +- src/server/shared/db/schema/movies.ts | 6 +- src/server/shared/db/schema/round-history.ts | 6 +- src/server/shared/db/schema/round-users.ts | 6 +- src/server/shared/db/schema/rounds.ts | 6 +- src/server/shared/db/schema/votes.ts | 6 +- src/server/shared/lib/env.ts | 6 +- src/shared/algorithm.ts | 70 +++--- src/shared/round-state.ts | 22 +- src/shared/types.ts | 33 +-- tests/shared/algorithm.test.ts | 38 +-- tests/shared/round-state.test.ts | 24 +- 40 files changed, 1053 insertions(+), 547 deletions(-) rename mise.toml => .mise.toml (64%) create mode 100644 CLAUDE.md create mode 100644 src/client/hooks/use-sw-update.ts create mode 100644 src/client/lib/sessions.ts create mode 100644 src/client/routes/$uuid/home.tsx diff --git a/.gitignore b/.gitignore index a47c985..75ee0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ dist/ .DS_Store data/ drizzle/meta/ +.mise.local.toml +.env.local +.env.*.local diff --git a/mise.toml b/.mise.toml similarity index 64% rename from mise.toml rename to .mise.toml index b406cd3..fa27325 100644 --- a/mise.toml +++ b/.mise.toml @@ -1,3 +1,2 @@ [tools] bun = "1.3.0" -node = "22" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..98fb318 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# movie-select — Collaborative Movie Picker + +Collaborative movie selection app where groups vote on movies to watch together. + +## Stack + +- **Frontend:** React 19, Vite, Tailwind CSS 4, TanStack Router, TanStack Query +- **Backend:** Hono (Bun), Drizzle ORM, PostgreSQL +- **Linting:** Biome 2.x (tabs, 80 chars, double quotes) + +## Project Structure + +``` +src/ +├── client/ ← React SPA (routes/, components/, hooks/, lib/) +├── server/ ← Hono API (features/, shared/) +└── shared/ ← isomorphic code (types, algorithms) +``` + +## Local Development + +```bash +bun install +bun run dev # frontend (Vite) +bun run dev:server # backend (Bun --watch) +``` + +## Deployment + +```bash +./deploy.sh +``` + +Deploys to Uberspace (`serve.uber.space`): +- Frontend → `/var/www/virtual/serve/html/movie-select/` +- Backend → `~/services/movie-select/` (systemd: `movie-select.service`, port 3003) +- Route: `/movie-select/api/*` → port 3003 (prefix removed) + +## Environment Variables + +See `.env.example`: +- `DATABASE_URL` — PostgreSQL connection string +- `PORT` — server port (default 3003) + +## Database + +PostgreSQL via Drizzle ORM. Migrations in `drizzle/`. + +```bash +bun run db:generate +bun run db:migrate +``` diff --git a/biome.json b/biome.json index 629048a..4e9b18a 100644 --- a/biome.json +++ b/biome.json @@ -11,7 +11,14 @@ } }, "formatter": { - "indentStyle": "tab" + "indentStyle": "tab", + "lineWidth": 80 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "asNeeded" + } }, "linter": { "enabled": true, diff --git a/bun.lock b/bun.lock index 5386e36..3ad1905 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,10 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "movie-select", "dependencies": { - "@hono/node-server": "^1.13.0", "@hono/zod-validator": "^0.7.6", "@tanstack/react-query": "^5.62.0", "@tanstack/react-router": "^1.92.0", @@ -146,8 +146,6 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], - "@hono/node-server": ["@hono/node-server@1.19.10", "", { "peerDependencies": { "hono": "^4" } }, "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw=="], - "@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="], "@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=="], diff --git a/deploy.sh b/deploy.sh index 2b753fe..efbfa55 100755 --- a/deploy.sh +++ b/deploy.sh @@ -11,6 +11,9 @@ bun install echo "==> Building client (Vite)..." bun run build +echo "==> Stamping service worker version..." +sed -i '' "s/__SW_VERSION__/$(date +%s)/" dist/client/sw.js + echo "==> Syncing static files to ${REMOTE_HOST}:${REMOTE_STATIC_DIR} ..." rsync -avz --delete \ --exclude='.DS_Store' \ diff --git a/index.html b/index.html index a3f26f2..c78e6db 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + diff --git a/package.json b/package.json index 7d3d72e..9d2eff2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "dev:server": "node --watch --env-file=.env src/server/index.ts", + "dev:server": "bun --watch --env-file=.env src/server/index.ts", "build": "vite build", "build:server": "true", "test": "vitest run", @@ -17,7 +17,6 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { - "@hono/node-server": "^1.13.0", "@hono/zod-validator": "^0.7.6", "@tanstack/react-query": "^5.62.0", "@tanstack/react-router": "^1.92.0", diff --git a/src/client/hooks/use-round-mutation.ts b/src/client/hooks/use-round-mutation.ts index 2812ac7..c7bbdb4 100644 --- a/src/client/hooks/use-round-mutation.ts +++ b/src/client/hooks/use-round-mutation.ts @@ -1,82 +1,98 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { State } from "@/shared/types.ts"; -import { api } from "../lib/api-client.ts"; +import { useMutation, useQueryClient } from "@tanstack/react-query" +import type { State } from "@/shared/types.ts" +import { api } from "../lib/api-client.ts" function useRoundMutation(uuid: string, mutationFn: () => Promise) { - const qc = useQueryClient(); + const qc = useQueryClient() return useMutation({ mutationFn, onSuccess: (state) => { - qc.setQueryData(["round", uuid], state); + qc.setQueryData(["round", uuid], state) }, - }); + }) +} + +export function useSetName(uuid: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (name: string) => api.setName(uuid, name), + onSuccess: (state) => qc.setQueryData(["round", uuid], state), + }) } export function useAddUser(uuid: string) { - const qc = useQueryClient(); + const qc = useQueryClient() return useMutation({ mutationFn: (user: string) => api.addUser(uuid, user), onSuccess: (state) => qc.setQueryData(["round", uuid], state), - }); + }) +} + +export function useRemoveUser(uuid: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (user: string) => api.removeUser(uuid, user), + onSuccess: (state) => qc.setQueryData(["round", uuid], state), + }) } export function useFinishSetup(uuid: string) { - return useRoundMutation(uuid, () => api.finishSetup(uuid)); + return useRoundMutation(uuid, () => api.finishSetup(uuid)) } export function useAddMovie(uuid: string) { - const qc = useQueryClient(); + const qc = useQueryClient() return useMutation({ mutationFn: ({ user, title }: { user: string; title: string }) => api.addMovie(uuid, user, title), onSuccess: (state) => qc.setQueryData(["round", uuid], state), - }); + }) } export function useRemoveMovie(uuid: string) { - const qc = useQueryClient(); + const qc = useQueryClient() return useMutation({ mutationFn: ({ user, title }: { user: string; title: string }) => api.removeMovie(uuid, user, title), onSuccess: (state) => qc.setQueryData(["round", uuid], state), - }); + }) } export function useVote(uuid: string) { - const qc = useQueryClient(); + const qc = useQueryClient() return useMutation({ mutationFn: ({ user, ratings, }: { - user: string; - ratings: Record; + user: string + ratings: Record }) => api.vote(uuid, user, ratings), onSuccess: (state) => qc.setQueryData(["round", uuid], state), - }); + }) } export function useMarkDone(uuid: string) { - const qc = useQueryClient(); + const qc = useQueryClient() return useMutation({ mutationFn: ({ user, phase }: { user: string; phase: 1 | 2 }) => api.markDone(uuid, user, phase), onSuccess: (state) => qc.setQueryData(["round", uuid], state), - }); + }) } export function useSetPhase(uuid: string) { - const qc = useQueryClient(); + const qc = useQueryClient() return useMutation({ mutationFn: (phase: 1 | 2 | 3) => api.setPhase(uuid, phase), onSuccess: (state) => qc.setQueryData(["round", uuid], state), - }); + }) } export function useNewRound(uuid: string) { - const qc = useQueryClient(); + const qc = useQueryClient() return useMutation({ mutationFn: (winner: string) => api.newRound(uuid, winner), onSuccess: (state) => qc.setQueryData(["round", uuid], state), - }); + }) } diff --git a/src/client/hooks/use-round.ts b/src/client/hooks/use-round.ts index 913a8a3..feb3d9c 100644 --- a/src/client/hooks/use-round.ts +++ b/src/client/hooks/use-round.ts @@ -1,5 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; -import { api } from "../lib/api-client.ts"; +import { useQuery } from "@tanstack/react-query" +import { api } from "../lib/api-client.ts" export function useRound(uuid: string) { return useQuery({ @@ -7,5 +7,5 @@ export function useRound(uuid: string) { queryFn: () => api.getState(uuid), enabled: uuid.length > 0, refetchInterval: 5000, - }); + }) } diff --git a/src/client/hooks/use-sw-update.ts b/src/client/hooks/use-sw-update.ts new file mode 100644 index 0000000..301cf8b --- /dev/null +++ b/src/client/hooks/use-sw-update.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react" + +export function useSwUpdate() { + const [waitingWorker, setWaitingWorker] = useState(null) + + useEffect(() => { + if (!("serviceWorker" in navigator)) return + + navigator.serviceWorker + .register("/movie-select/sw.js") + .then((registration) => { + // Already a waiting worker from a previous visit + if (registration.waiting) { + setWaitingWorker(registration.waiting) + } + + registration.addEventListener("updatefound", () => { + const newWorker = registration.installing + if (!newWorker) return + + newWorker.addEventListener("statechange", () => { + if ( + newWorker.state === "installed" && + navigator.serviceWorker.controller + ) { + setWaitingWorker(newWorker) + } + }) + }) + }) + + // Reload when the new worker takes over + let refreshing = false + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (refreshing) return + refreshing = true + window.location.reload() + }) + }, []) + + function applyUpdate() { + waitingWorker?.postMessage("skipWaiting") + } + + return { updateAvailable: waitingWorker !== null, applyUpdate } +} diff --git a/src/client/index.css b/src/client/index.css index 4b98750..1e66a47 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -8,6 +8,7 @@ @layer base { html { -webkit-tap-highlight-color: transparent; + touch-action: pan-x pan-y; } button, input, @@ -51,3 +52,8 @@ input[type="range"]::-moz-range-thumb { .safe-b { padding-bottom: max(2.5rem, env(safe-area-inset-bottom)); } + +/* Safe-area padding for fixed bottom tab bar */ +.safe-b-tab { + padding-bottom: env(safe-area-inset-bottom); +} diff --git a/src/client/lib/api-client.ts b/src/client/lib/api-client.ts index f9174c4..ff751fe 100644 --- a/src/client/lib/api-client.ts +++ b/src/client/lib/api-client.ts @@ -1,11 +1,11 @@ -import type { State } from "@/shared/types.ts"; +import type { State } from "@/shared/types.ts" -const BASE = "/movie-select/api/rounds"; +const BASE = "/movie-select/api/rounds" interface ApiResponse { - ok: boolean; - state: State; - error?: string; + ok: boolean + state: State + error?: string } async function request( @@ -17,16 +17,20 @@ async function request( method, headers: body ? { "Content-Type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, - }); - const data = (await res.json()) as ApiResponse; - if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed"); - return data.state; + }) + const data = (await res.json()) as ApiResponse + if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed") + return data.state } export const api = { getState: (uuid: string) => request("GET", `/${uuid}`), addUser: (uuid: string, user: string) => request("POST", `/${uuid}/users`, { user }), + setName: (uuid: string, name: string) => + request("PATCH", `/${uuid}`, { name }), + removeUser: (uuid: string, user: string) => + request("DELETE", `/${uuid}/users`, { user }), finishSetup: (uuid: string) => request("POST", `/${uuid}/setup`), addMovie: (uuid: string, user: string, title: string) => request("POST", `/${uuid}/movies`, { user, title }), @@ -40,4 +44,4 @@ export const api = { request("POST", `/${uuid}/phase`, { phase }), newRound: (uuid: string, winner: string) => request("POST", `/${uuid}/new-round`, { winner }), -}; +} diff --git a/src/client/lib/sessions.ts b/src/client/lib/sessions.ts new file mode 100644 index 0000000..cd85c98 --- /dev/null +++ b/src/client/lib/sessions.ts @@ -0,0 +1,36 @@ +const STORAGE_KEY = "movie-select:sessions" +const MAX_SESSIONS = 20 + +export interface Session { + uuid: string + createdAt: string + role: "admin" | "user" + userName?: string + sessionName?: string +} + +export function getSessions(): Session[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + return JSON.parse(raw) as Session[] + } catch { + return [] + } +} + +export function saveSession(session: Session): void { + const sessions = getSessions().filter((s) => s.uuid !== session.uuid) + sessions.unshift(session) + if (sessions.length > MAX_SESSIONS) sessions.length = MAX_SESSIONS + localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)) +} + +export function removeSession(uuid: string): void { + const sessions = getSessions().filter((s) => s.uuid !== uuid) + localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)) +} + +export function clearSessions(): void { + localStorage.removeItem(STORAGE_KEY) +} diff --git a/src/client/lib/utils.ts b/src/client/lib/utils.ts index ac680b3..5e1abdf 100644 --- a/src/client/lib/utils.ts +++ b/src/client/lib/utils.ts @@ -1,6 +1,6 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)) } diff --git a/src/client/main.tsx b/src/client/main.tsx index 8dd0e66..6555873 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { App } from "./router.tsx"; -import "./index.css"; +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { App } from "./router.tsx" +import "./index.css" createRoot(document.getElementById("root")!).render( , -); +) diff --git a/src/client/router.tsx b/src/client/router.tsx index 4c95775..48fc6d7 100644 --- a/src/client/router.tsx +++ b/src/client/router.tsx @@ -1,6 +1,6 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createRouter, RouterProvider } from "@tanstack/react-router"; -import { routeTree } from "./routes/__root.tsx"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { createRouter, RouterProvider } from "@tanstack/react-router" +import { routeTree } from "./routes/__root.tsx" const queryClient = new QueryClient({ defaultOptions: { @@ -8,17 +8,17 @@ const queryClient = new QueryClient({ staleTime: 2000, }, }, -}); +}) const router = createRouter({ routeTree, basepath: "/movie-select", -}); +}) export function App() { return ( - ); + ) } diff --git a/src/client/routes/$uuid/admin.tsx b/src/client/routes/$uuid/admin.tsx index 715ceea..c9d61d5 100644 --- a/src/client/routes/$uuid/admin.tsx +++ b/src/client/routes/$uuid/admin.tsx @@ -1,47 +1,96 @@ -import { useParams } from "@tanstack/react-router"; -import { useRef, useState } from "react"; -import { decideMovie } from "@/shared/algorithm.ts"; -import { collectCompleteRatings } from "@/shared/round-state.ts"; -import type { State } from "@/shared/types.ts"; -import { useRound } from "../../hooks/use-round.ts"; +import { useParams } from "@tanstack/react-router" +import { useEffect, useRef, useState } from "react" +import { decideMovie } from "@/shared/algorithm.ts" +import { collectCompleteRatings } from "@/shared/round-state.ts" +import type { State } from "@/shared/types.ts" +import { useRound } from "../../hooks/use-round.ts" import { useAddUser, useFinishSetup, useNewRound, -} from "../../hooks/use-round-mutation.ts"; -import { ResultSection } from "./route.tsx"; + useRemoveUser, + useSetName, +} from "../../hooks/use-round-mutation.ts" +import { saveSession } from "../../lib/sessions.ts" +import { ResultSection } from "./route.tsx" export function AdminRoute() { - const { uuid } = useParams({ strict: false }) as { uuid: string }; - const { data: state, refetch } = useRound(uuid); + const { uuid } = useParams({ strict: false }) as { uuid: string } + const { data: state, refetch } = useRound(uuid) + + const roundName = state?.name + useEffect(() => { + if (roundName !== undefined) { + saveSession({ + uuid, + role: "admin", + createdAt: new Date().toISOString(), + sessionName: roundName ?? undefined, + }) + } + }, [uuid, roundName]) if (!state) { - return

Loading…

; + return

Loading…

} if (!state.setupDone) { - return ; + return } - return ; + return } function AdminSetup({ uuid, state }: { uuid: string; state: State }) { - const [draft, setDraft] = useState(""); - const inputRef = useRef(null); - const addUser = useAddUser(uuid); - const finishSetup = useFinishSetup(uuid); + const [draft, setDraft] = useState("") + const [nameDraft, setNameDraft] = useState(state.name ?? "") + const inputRef = useRef(null) + const setName = useSetName(uuid) + const addUser = useAddUser(uuid) + const removeUser = useRemoveUser(uuid) + const finishSetup = useFinishSetup(uuid) async function handleAdd() { - const name = draft.trim(); - if (!name) return; - await addUser.mutateAsync(name); - setDraft(""); - inputRef.current?.focus(); + const name = draft.trim() + if (!name) return + await addUser.mutateAsync(name) + setDraft("") + inputRef.current?.focus() + } + + function handleNameBlur() { + const trimmed = nameDraft.trim() + if (trimmed !== (state.name ?? "")) { + setName.mutate(trimmed) + } } return ( <>

+ Session Name{" "} + · optional +

+
+ setNameDraft(e.target.value)} + onBlur={handleNameBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + ;(e.target as HTMLInputElement).blur() + } + }} + /> +
+

Add People

@@ -61,8 +110,8 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) { onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { - e.preventDefault(); - handleAdd(); + e.preventDefault() + handleAdd() } }} /> @@ -77,11 +126,15 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) {

    {state.users.map((name) => ( -
  • - {name} +
  • +
  • ))}
@@ -95,13 +148,13 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) { Next → - {(addUser.error || finishSetup.error) && ( + {(addUser.error || removeUser.error || finishSetup.error) && (

- {(addUser.error ?? finishSetup.error)?.message} + {(addUser.error ?? removeUser.error ?? finishSetup.error)?.message}

)} - ); + ) } function AdminStatus({ @@ -109,46 +162,54 @@ function AdminStatus({ state, onRefresh, }: { - uuid: string; - state: State; - onRefresh: () => void; + uuid: string + state: State + onRefresh: () => void }) { - const newRound = useNewRound(uuid); - const d1 = state.doneUsersPhase1 ?? []; - const d2 = state.doneUsersPhase2 ?? []; - const totalSteps = state.users.length * 2; - const completedSteps = d1.length + d2.length; + const newRound = useNewRound(uuid) + const d1 = state.doneUsersPhase1 ?? [] + const d2 = state.doneUsersPhase2 ?? [] + const totalSteps = state.users.length * 2 + const completedSteps = d1.length + d2.length const phaseTitle = state.phase === 1 ? "Phase 1 · Movie Collection" : state.phase === 2 ? "Phase 2 · Voting" - : "Phase 3 · Results"; + : "Phase 3 · Results" function computeWinner(): string { - const movieTitles = state.movies.map((m) => m.title); - if (movieTitles.length === 0) return ""; + const movieTitles = state.movies.map((m) => m.title) + if (movieTitles.length === 0) return "" const ratings = collectCompleteRatings( state.users, movieTitles, state.votes ?? {}, - ); - const voters = Object.keys(ratings); - if (voters.length === 0) return ""; + ) + const voters = Object.keys(ratings) + if (voters.length === 0) return "" return decideMovie({ movies: movieTitles, people: voters, ratings, - }).winner.movie; + }).winner.movie } function handleCopy(link: string, btn: HTMLButtonElement) { navigator.clipboard.writeText(link).then(() => { - btn.textContent = "✓ Copied"; + btn.textContent = "✓ Copied" setTimeout(() => { - btn.textContent = "Copy"; - }, 1400); - }); + btn.textContent = "Copy" + }, 1400) + }) + } + + function handleShare(link: string, name: string) { + if (navigator.share) { + navigator.share({ title: `Movie Select — ${name}`, url: link }) + } else { + navigator.clipboard.writeText(link) + } } return ( @@ -166,9 +227,9 @@ function AdminStatus({

    {state.users.map((name) => { - const link = `${window.location.origin}/movie-select/${uuid}?user=${encodeURIComponent(name)}`; + const link = `${window.location.origin}/movie-select/${uuid}?user=${encodeURIComponent(name)}` const steps = - (d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0); + (d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0) return (
  • {name} -
    - + +
    + + Open + +
  • - ); + ) })}
@@ -224,7 +311,7 @@ function AdminStatus({ )} - ); + ) } function StatusBadge({ steps }: { steps: number }) { @@ -233,18 +320,18 @@ function StatusBadge({ steps }: { steps: number }) { Done - ); + ) if (steps === 1) return ( 1/2 - ); + ) return ( 0/2 - ); + ) } function ProgressCard({ @@ -252,11 +339,11 @@ function ProgressCard({ done, total, }: { - title: string; - done: number; - total: number; + title: string + done: number + total: number }) { - const pct = total > 0 ? Math.round((done / total) * 100) : 0; + const pct = total > 0 ? Math.round((done / total) * 100) : 0 return (

{title}

@@ -272,5 +359,5 @@ function ProgressCard({
- ); + ) } diff --git a/src/client/routes/$uuid/home.tsx b/src/client/routes/$uuid/home.tsx new file mode 100644 index 0000000..5e8ccc5 --- /dev/null +++ b/src/client/routes/$uuid/home.tsx @@ -0,0 +1,99 @@ +import { Link, useNavigate } from "@tanstack/react-router" +import { useState } from "react" +import { + clearSessions, + getSessions, + type Session, + saveSession, +} from "../../lib/sessions.ts" + +export function HomeRoute() { + const navigate = useNavigate() + const [sessions, setSessions] = useState(getSessions) + + function handleCreate() { + const uuid = crypto.randomUUID() + saveSession({ uuid, role: "admin", createdAt: new Date().toISOString() }) + navigate({ to: "/$uuid/admin", params: { uuid } }) + } + + function handleClear() { + clearSessions() + setSessions([]) + } + + return ( + <> +

+ Home +

+
+ +
+ {sessions.length > 0 && ( + <> +

+ Recent Sessions +

+
    + {sessions.map((s) => ( + + ))} +
+
+ +
+ + )} + + ) +} + +function SessionRow({ session }: { session: Session }) { + const date = new Date(session.createdAt) + const dateStr = date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: + date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined, + }) + + const isAdmin = session.role === "admin" + + return ( +
  • + + + {isAdmin ? "Admin" : "User"} + + + {session.sessionName ?? session.userName ?? session.uuid.slice(0, 8)} + + {dateStr} + +
  • + ) +} diff --git a/src/client/routes/$uuid/route.tsx b/src/client/routes/$uuid/route.tsx index 798ee99..09a36d0 100644 --- a/src/client/routes/$uuid/route.tsx +++ b/src/client/routes/$uuid/route.tsx @@ -1,24 +1,37 @@ -import { useParams, useSearch } from "@tanstack/react-router"; -import { useRef, useState } from "react"; -import { decideMovie } from "@/shared/algorithm.ts"; -import { collectCompleteRatings } from "@/shared/round-state.ts"; -import type { State } from "@/shared/types.ts"; -import { useRound } from "../../hooks/use-round.ts"; +import { useParams, useSearch } from "@tanstack/react-router" +import { useEffect, useRef, useState } from "react" +import { decideMovie } from "@/shared/algorithm.ts" +import { collectCompleteRatings } from "@/shared/round-state.ts" +import type { State } from "@/shared/types.ts" +import { useRound } from "../../hooks/use-round.ts" import { useAddMovie, useMarkDone, useRemoveMovie, useVote, -} from "../../hooks/use-round-mutation.ts"; +} from "../../hooks/use-round-mutation.ts" +import { saveSession } from "../../lib/sessions.ts" export function UserRoute() { - const { uuid } = useParams({ strict: false }) as { uuid: string }; - const search = useSearch({ strict: false }) as Record; - const userName = (search.user ?? "").trim(); - const { data: state, refetch } = useRound(uuid); + const { uuid } = useParams({ strict: false }) as { uuid: string } + const search = useSearch({ strict: false }) as Record + const userName = (search.user ?? "").trim() + const { data: state, refetch } = useRound(uuid) + + useEffect(() => { + if (userName && state?.setupDone) { + saveSession({ + uuid, + role: "user", + userName, + createdAt: new Date().toISOString(), + sessionName: state.name ?? undefined, + }) + } + }, [uuid, userName, state?.setupDone, state?.name]) if (!state) { - return

    Loading…

    ; + return

    Loading…

    } if (!state.setupDone) { @@ -31,11 +44,11 @@ export function UserRoute() { - ); + ) } if (!userName) { - return ; + return } if (state.users.length > 0 && !state.users.includes(userName)) { @@ -43,13 +56,13 @@ export function UserRoute() {

    Unknown user — use your invite link.

    - ); + ) } if (state.phase === 1) { - const isDone = state.doneUsersPhase1?.includes(userName); + const isDone = state.doneUsersPhase1?.includes(userName) if (isDone) { - return ; + return } return ( - ); + ) } if (state.phase === 2) { - const isDone = state.doneUsersPhase2?.includes(userName); + const isDone = state.doneUsersPhase2?.includes(userName) if (isDone) { - return ; + return } return ( - ); + ) } - return ; + return } function AskUserName() { - const inputRef = useRef(null); + const inputRef = useRef(null) function handleSubmit() { - const v = inputRef.current?.value.trim(); - if (!v) return; - const params = new URLSearchParams(window.location.search); - params.set("user", v); - window.location.search = params.toString(); + const v = inputRef.current?.value.trim() + if (!v) return + const params = new URLSearchParams(window.location.search) + params.set("user", v) + window.location.search = params.toString() } return ( <> @@ -103,7 +116,7 @@ function AskUserName() { aria-label="Your name" className="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 focus:border-blue-400 focus:shadow-[0_0_0_3px_rgba(96,165,250,0.2)] flex-1" onKeyDown={(e) => { - if (e.key === "Enter") handleSubmit(); + if (e.key === "Enter") handleSubmit() }} /> - ); + ) } function ErrorMsg({ error }: { error: Error | null | undefined }) { - if (!error) return null; + if (!error) return null return (

    {error.message}

    - ); + ) } function getHistoryItems( state: State, ): Array<{ display: string; value: string }> { - const history = state.history ?? []; - if (history.length === 0) return []; + const history = state.history ?? [] + if (history.length === 0) return [] const currentTitles = new Set( state.movies.map((m) => m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "").toLowerCase(), ), - ); - const wonMovies: string[] = []; - const regularMovies = new Set(); + ) + 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); + const round = history[i]! + if (round.winner) wonMovies.push(round.winner) for (const m of round.movies ?? []) { - regularMovies.add(m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "")); + regularMovies.add(m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "")) } } const wonSet = new Set( wonMovies.map((t) => t.replace(/^(?:\u{1F3C6}\s*)+/u, "").toLowerCase()), - ); + ) const regularList = [...regularMovies] .filter( (t) => !wonSet.has(t.toLowerCase()) && !currentTitles.has(t.toLowerCase()), ) - .sort((a, b) => a.localeCompare(b)); + .sort((a, b) => a.localeCompare(b)) - const seenWon = new Set(); - const wonList: Array<{ display: string; value: string }> = []; + const seenWon = new Set() + const wonList: Array<{ display: string; value: string }> = [] for (const w of wonMovies) { - const clean = w.replace(/^(?:\u{1F3C6}\s*)+/u, ""); - const key = clean.toLowerCase(); - if (!clean || seenWon.has(key) || currentTitles.has(key)) continue; - seenWon.add(key); - wonList.push({ display: `🏆 ${clean}`, value: clean }); + const clean = w.replace(/^(?:\u{1F3C6}\s*)+/u, "") + const key = clean.toLowerCase() + if (!clean || seenWon.has(key) || currentTitles.has(key)) continue + seenWon.add(key) + wonList.push({ display: `🏆 ${clean}`, value: clean }) } - return [...regularList.map((t) => ({ display: t, value: t })), ...wonList]; + return [...regularList.map((t) => ({ display: t, value: t })), ...wonList] } diff --git a/src/client/routes/__root.tsx b/src/client/routes/__root.tsx index 921f997..2f7ab24 100644 --- a/src/client/routes/__root.tsx +++ b/src/client/routes/__root.tsx @@ -1,12 +1,42 @@ -import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"; -import { AdminRoute } from "./$uuid/admin.tsx"; -import { UserRoute } from "./$uuid/route.tsx"; -import { CreateScreen } from "./index.tsx"; +import { + createRootRoute, + createRoute, + Link, + Outlet, + useLocation, + useParams, +} from "@tanstack/react-router" +import { useSwUpdate } from "../hooks/use-sw-update.ts" +import { AdminRoute } from "./$uuid/admin.tsx" +import { HomeRoute } from "./$uuid/home.tsx" +import { UserRoute } from "./$uuid/route.tsx" +import { CreateScreen } from "./index.tsx" function RootLayout() { + const params = useParams({ strict: false }) as { uuid?: string } + const location = useLocation() + const uuid = params.uuid + const isAdmin = location.pathname.endsWith("/admin") + const isHome = location.pathname.endsWith("/home") + const { updateAvailable, applyUpdate } = useSwUpdate() + return (
    -
    + {updateAvailable && ( +
    + A new version is available. + +
    + )} +

    Movie Select

    @@ -14,34 +44,83 @@ function RootLayout() {
    + {uuid && (isAdmin || isHome) && ( + + )}
    - ); + ) } const rootRoute = createRootRoute({ component: RootLayout, -}); +}) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", component: CreateScreen, -}); +}) + +const uuidHomeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/$uuid/home", + component: HomeRoute, +}) const uuidAdminRoute = createRoute({ getParentRoute: () => rootRoute, path: "/$uuid/admin", component: AdminRoute, -}); +}) const uuidUserRoute = createRoute({ getParentRoute: () => rootRoute, path: "/$uuid", component: UserRoute, -}); +}) export const routeTree = rootRoute.addChildren([ indexRoute, + uuidHomeRoute, uuidAdminRoute, uuidUserRoute, -]); +]) diff --git a/src/client/routes/index.tsx b/src/client/routes/index.tsx index a071b2d..8bb95ea 100644 --- a/src/client/routes/index.tsx +++ b/src/client/routes/index.tsx @@ -1,11 +1,13 @@ -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router" +import { saveSession } from "../lib/sessions.ts" export function CreateScreen() { - const navigate = useNavigate(); + const navigate = useNavigate() function handleCreate() { - const uuid = crypto.randomUUID(); - navigate({ to: "/$uuid/admin", params: { uuid } }); + const uuid = crypto.randomUUID() + saveSession({ uuid, role: "admin", createdAt: new Date().toISOString() }) + navigate({ to: "/$uuid/admin", params: { uuid } }) } return ( @@ -26,5 +28,5 @@ export function CreateScreen() { - ); + ) } diff --git a/src/server/app.ts b/src/server/app.ts index 5163d7e..309bb52 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,20 +1,20 @@ -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { roundsRouter } from "./features/rounds/router.ts"; -import { ApiError } from "./features/rounds/service.ts"; +import { Hono } from "hono" +import { cors } from "hono/cors" +import { roundsRouter } from "./features/rounds/router.ts" +import { ApiError } from "./features/rounds/service.ts" -const app = new Hono(); +const app = new Hono() -app.use("*", cors()); +app.use("*", cors()) -app.route("/rounds", roundsRouter); +app.route("/rounds", roundsRouter) app.onError((err, c) => { if (err instanceof ApiError) { - return c.json({ ok: false, error: err.message }, err.status as 400); + return c.json({ ok: false, error: err.message }, err.status as 400) } - console.error(err); - return c.json({ ok: false, error: "Internal server error" }, 500); -}); + console.error(err) + return c.json({ ok: false, error: "Internal server error" }, 500) +}) -export default app; +export default app diff --git a/src/server/features/rounds/router.ts b/src/server/features/rounds/router.ts index da8030e..f9dd692 100644 --- a/src/server/features/rounds/router.ts +++ b/src/server/features/rounds/router.ts @@ -1,40 +1,66 @@ -import { zValidator } from "@hono/zod-validator"; -import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator" +import { Hono } from "hono" import { addMovieBody, addUserBody, markDoneBody, newRoundBody, removeMovieBody, + removeUserBody, + setNameBody, setPhaseBody, uuidParam, voteBody, -} from "./schema.ts"; -import * as service from "./service.ts"; +} from "./schema.ts" +import * as service from "./service.ts" export const roundsRouter = new Hono() .get("/:uuid", zValidator("param", uuidParam), async (c) => { - const { uuid } = c.req.valid("param"); - const state = await service.loadState(uuid); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const state = await service.loadState(uuid) + return c.json({ ok: true, state }) }) + .patch( + "/:uuid", + zValidator("param", uuidParam), + zValidator("json", setNameBody), + async (c) => { + const { uuid } = c.req.valid("param") + const { name } = c.req.valid("json") + const state = await service.setName(uuid, name) + return c.json({ ok: true, state }) + }, + ) + .post( "/:uuid/users", zValidator("param", uuidParam), zValidator("json", addUserBody), async (c) => { - const { uuid } = c.req.valid("param"); - const { user } = c.req.valid("json"); - const state = await service.addUser(uuid, user); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const { user } = c.req.valid("json") + const state = await service.addUser(uuid, user) + return c.json({ ok: true, state }) + }, + ) + + .delete( + "/:uuid/users", + zValidator("param", uuidParam), + zValidator("json", removeUserBody), + async (c) => { + const { uuid } = c.req.valid("param") + const { user } = c.req.valid("json") + const state = await service.removeUser(uuid, user) + return c.json({ ok: true, state }) }, ) .post("/:uuid/setup", zValidator("param", uuidParam), async (c) => { - const { uuid } = c.req.valid("param"); - const state = await service.finishSetup(uuid); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const state = await service.finishSetup(uuid) + return c.json({ ok: true, state }) }) .post( @@ -42,10 +68,10 @@ export const roundsRouter = new Hono() zValidator("param", uuidParam), zValidator("json", addMovieBody), async (c) => { - const { uuid } = c.req.valid("param"); - const { user, title } = c.req.valid("json"); - const state = await service.addMovie(uuid, user, title); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const { user, title } = c.req.valid("json") + const state = await service.addMovie(uuid, user, title) + return c.json({ ok: true, state }) }, ) @@ -54,10 +80,10 @@ export const roundsRouter = new Hono() zValidator("param", uuidParam), zValidator("json", removeMovieBody), async (c) => { - const { uuid } = c.req.valid("param"); - const { user, title } = c.req.valid("json"); - const state = await service.removeMovie(uuid, user, title); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const { user, title } = c.req.valid("json") + const state = await service.removeMovie(uuid, user, title) + return c.json({ ok: true, state }) }, ) @@ -66,10 +92,10 @@ export const roundsRouter = new Hono() zValidator("param", uuidParam), zValidator("json", voteBody), async (c) => { - const { uuid } = c.req.valid("param"); - const { user, ratings } = c.req.valid("json"); - const state = await service.voteMany(uuid, user, ratings); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const { user, ratings } = c.req.valid("json") + const state = await service.voteMany(uuid, user, ratings) + return c.json({ ok: true, state }) }, ) @@ -78,10 +104,10 @@ export const roundsRouter = new Hono() zValidator("param", uuidParam), zValidator("json", markDoneBody), async (c) => { - const { uuid } = c.req.valid("param"); - const { user, phase } = c.req.valid("json"); - const state = await service.markDone(uuid, user, phase); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const { user, phase } = c.req.valid("json") + const state = await service.markDone(uuid, user, phase) + return c.json({ ok: true, state }) }, ) @@ -90,10 +116,10 @@ export const roundsRouter = new Hono() zValidator("param", uuidParam), zValidator("json", setPhaseBody), async (c) => { - const { uuid } = c.req.valid("param"); - const { phase } = c.req.valid("json"); - const state = await service.setPhase(uuid, phase); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const { phase } = c.req.valid("json") + const state = await service.setPhase(uuid, phase) + return c.json({ ok: true, state }) }, ) @@ -102,9 +128,9 @@ export const roundsRouter = new Hono() zValidator("param", uuidParam), zValidator("json", newRoundBody), async (c) => { - const { uuid } = c.req.valid("param"); - const { winner } = c.req.valid("json"); - const state = await service.newRound(uuid, winner); - return c.json({ ok: true, state }); + const { uuid } = c.req.valid("param") + const { winner } = c.req.valid("json") + const state = await service.newRound(uuid, winner) + return c.json({ ok: true, state }) }, - ); + ) diff --git a/src/server/features/rounds/schema.ts b/src/server/features/rounds/schema.ts index bafc74e..44ad0c4 100644 --- a/src/server/features/rounds/schema.ts +++ b/src/server/features/rounds/schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod" export const uuidParam = z.object({ uuid: z @@ -7,36 +7,44 @@ export const uuidParam = z.object({ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, "Invalid UUID", ), -}); +}) + +export const setNameBody = z.object({ + name: z.string().trim().max(60, "Name too long"), +}) export const addUserBody = z.object({ user: z.string().trim().min(1, "Name required").max(30, "Name too long"), -}); +}) + +export const removeUserBody = z.object({ + user: z.string().trim().min(1, "Name required").max(30, "Name too long"), +}) export const addMovieBody = z.object({ user: z.string().trim().min(1, "Name required").max(30, "Name too long"), title: z.string().trim().min(1, "Title required").max(60, "Title too long"), -}); +}) export const removeMovieBody = z.object({ user: z.string().trim().min(1, "Name required").max(30, "Name too long"), title: z.string().trim().min(1, "Title required").max(60, "Title too long"), -}); +}) export const voteBody = z.object({ user: z.string().trim().min(1, "Name required").max(30, "Name too long"), ratings: z.record(z.string(), z.number().int().min(0).max(5)), -}); +}) export const markDoneBody = z.object({ user: z.string().trim().min(1, "Name required").max(30, "Name too long"), phase: z.union([z.literal(1), z.literal(2)]), -}); +}) export const setPhaseBody = z.object({ phase: z.union([z.literal(1), z.literal(2), z.literal(3)]), -}); +}) export const newRoundBody = z.object({ winner: z.string().default(""), -}); +}) diff --git a/src/server/features/rounds/service.ts b/src/server/features/rounds/service.ts index 83810b5..b1e152d 100644 --- a/src/server/features/rounds/service.ts +++ b/src/server/features/rounds/service.ts @@ -1,33 +1,33 @@ -import { and, asc, eq } from "drizzle-orm"; -import type { Movie, State } from "@/shared/types.ts"; -import { db } from "../../shared/db/index.ts"; +import { and, asc, eq } from "drizzle-orm" +import type { Movie, State } from "@/shared/types.ts" +import { db } from "../../shared/db/index.ts" import { movies, roundHistory, rounds, roundUsers, votes, -} from "../../shared/db/schema/index.ts"; +} from "../../shared/db/schema/index.ts" -const TROPHY_RE = /^(?:\u{1F3C6}\s*)+/u; +const TROPHY_RE = /^(?:\u{1F3C6}\s*)+/u export class ApiError extends Error { constructor( public readonly status: number, message: string, ) { - super(message); + super(message) } } function fail(status: number, message: string): never { - throw new ApiError(status, message); + throw new ApiError(status, message) } function cleanTitle(value: string): string { - const trimmed = value.trim().replace(TROPHY_RE, ""); - if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title"); - return trimmed; + const trimmed = value.trim().replace(TROPHY_RE, "") + if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title") + return trimmed } export async function loadState(uuid: string): Promise { @@ -35,17 +35,18 @@ export async function loadState(uuid: string): Promise { await db .insert(rounds) .values({ uuid }) - .onConflictDoNothing({ target: rounds.uuid }); + .onConflictDoNothing({ target: rounds.uuid }) const [round] = await db .select({ + name: rounds.name, phase: rounds.phase, setupDone: rounds.setupDone, createdAt: rounds.createdAt, updatedAt: rounds.updatedAt, }) .from(rounds) - .where(eq(rounds.uuid, uuid)); + .where(eq(rounds.uuid, uuid)) const userRows = await db .select({ @@ -55,7 +56,7 @@ export async function loadState(uuid: string): Promise { }) .from(roundUsers) .where(eq(roundUsers.roundUuid, uuid)) - .orderBy(asc(roundUsers.sortOrder), asc(roundUsers.name)); + .orderBy(asc(roundUsers.sortOrder), asc(roundUsers.name)) const movieRows = await db .select({ @@ -65,7 +66,7 @@ export async function loadState(uuid: string): Promise { }) .from(movies) .where(eq(movies.roundUuid, uuid)) - .orderBy(asc(movies.id)); + .orderBy(asc(movies.id)) const voteRows = await db .select({ @@ -74,7 +75,7 @@ export async function loadState(uuid: string): Promise { rating: votes.rating, }) .from(votes) - .where(eq(votes.roundUuid, uuid)); + .where(eq(votes.roundUuid, uuid)) const historyRows = await db .select({ @@ -83,27 +84,28 @@ export async function loadState(uuid: string): Promise { }) .from(roundHistory) .where(eq(roundHistory.roundUuid, uuid)) - .orderBy(asc(roundHistory.id)); + .orderBy(asc(roundHistory.id)) - const users = userRows.map((u) => u.name); - const donePhase1 = userRows.filter((u) => u.donePhase1).map((u) => u.name); - const donePhase2 = userRows.filter((u) => u.donePhase2).map((u) => u.name); + const users = userRows.map((u) => u.name) + const donePhase1 = userRows.filter((u) => u.donePhase1).map((u) => u.name) + const donePhase2 = userRows.filter((u) => u.donePhase2).map((u) => u.name) - const votesMap: Record> = {}; + const votesMap: Record> = {} for (const v of voteRows) { - if (!votesMap[v.userName]) votesMap[v.userName] = {}; - votesMap[v.userName][v.movieTitle] = v.rating; + if (!votesMap[v.userName]) votesMap[v.userName] = {} + votesMap[v.userName][v.movieTitle] = v.rating } const history = historyRows.map((h) => ({ winner: h.winner, movies: JSON.parse(h.moviesJson) as Movie[], - })); + })) - const phase = Number(round?.phase ?? 1); + const phase = Number(round?.phase ?? 1) return { uuid, + name: round?.name ?? null, phase: (phase === 1 || phase === 2 || phase === 3 ? phase : 1) as 1 | 2 | 3, setupDone: Boolean(round?.setupDone), users, @@ -118,30 +120,46 @@ export async function loadState(uuid: string): Promise { history, createdAt: round?.createdAt?.toISOString() ?? new Date().toISOString(), updatedAt: round?.updatedAt?.toISOString() ?? new Date().toISOString(), - }; + } +} + +export async function setName(uuid: string, name: string): Promise { + await loadState(uuid) + const trimmed = name.trim() || null + await db.update(rounds).set({ name: trimmed }).where(eq(rounds.uuid, uuid)) + return loadState(uuid) } export async function addUser(uuid: string, user: string): Promise { - const state = await loadState(uuid); - if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed"); + const state = await loadState(uuid) + if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed") if (!state.users.includes(user)) { await db .insert(roundUsers) .values({ roundUuid: uuid, name: user, sortOrder: state.users.length }) - .onConflictDoNothing(); + .onConflictDoNothing() } - return loadState(uuid); + return loadState(uuid) +} + +export async function removeUser(uuid: string, user: string): Promise { + const state = await loadState(uuid) + if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed") + await db + .delete(roundUsers) + .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))) + return loadState(uuid) } export async function finishSetup(uuid: string): Promise { - const state = await loadState(uuid); - if (state.users.length < 1) fail(409, "Add at least one user first"); - await db.update(rounds).set({ setupDone: true }).where(eq(rounds.uuid, uuid)); + const state = await loadState(uuid) + if (state.users.length < 1) fail(409, "Add at least one user first") + await db.update(rounds).set({ setupDone: true }).where(eq(rounds.uuid, uuid)) await db .update(roundUsers) .set({ donePhase1: false, donePhase2: false }) - .where(eq(roundUsers.roundUuid, uuid)); - return loadState(uuid); + .where(eq(roundUsers.roundUuid, uuid)) + return loadState(uuid) } export async function addMovie( @@ -149,29 +167,29 @@ export async function addMovie( user: string, rawTitle: string, ): Promise { - const state = await loadState(uuid); - if (!state.setupDone) fail(409, "Setup is not finished"); - if (state.phase !== 1) fail(409, "Movie phase is closed"); + const state = await loadState(uuid) + if (!state.setupDone) fail(409, "Setup is not finished") + if (state.phase !== 1) fail(409, "Movie phase is closed") if (state.users.length > 0 && !state.users.includes(user)) - fail(403, "Unknown user"); + fail(403, "Unknown user") - const title = cleanTitle(rawTitle); + const title = cleanTitle(rawTitle) const myCount = state.movies.filter( (m) => m.addedBy.toLowerCase() === user.toLowerCase(), - ).length; - if (myCount >= 5) fail(409, "Movie limit reached for this user (5)"); + ).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"); + ) + if (duplicate) fail(409, "Movie already exists") - await db.insert(movies).values({ roundUuid: uuid, title, addedBy: user }); + await db.insert(movies).values({ roundUuid: uuid, title, addedBy: user }) await db .update(roundUsers) .set({ donePhase1: false }) - .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))); - return loadState(uuid); + .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))) + return loadState(uuid) } export async function removeMovie( @@ -179,13 +197,13 @@ export async function removeMovie( user: string, rawTitle: string, ): Promise { - const state = await loadState(uuid); - if (!state.setupDone) fail(409, "Setup is not finished"); - if (state.phase !== 1) fail(409, "Movie phase is closed"); + const state = await loadState(uuid) + if (!state.setupDone) fail(409, "Setup is not finished") + if (state.phase !== 1) fail(409, "Movie phase is closed") if (state.users.length > 0 && !state.users.includes(user)) - fail(403, "Unknown user"); + fail(403, "Unknown user") - const title = cleanTitle(rawTitle); + const title = cleanTitle(rawTitle) await db .delete(movies) .where( @@ -194,12 +212,12 @@ export async function removeMovie( eq(movies.title, title), eq(movies.addedBy, user), ), - ); + ) await db .update(roundUsers) .set({ donePhase1: false }) - .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))); - return loadState(uuid); + .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))) + return loadState(uuid) } export async function voteMany( @@ -207,16 +225,16 @@ export async function voteMany( user: string, ratings: Record, ): Promise { - const state = await loadState(uuid); - if (state.phase !== 2) fail(409, "Voting phase is not active"); + const state = await loadState(uuid) + if (state.phase !== 2) fail(409, "Voting phase is not active") if (state.users.length > 0 && !state.users.includes(user)) - fail(403, "Unknown user"); + fail(403, "Unknown user") for (const movie of state.movies) { - const rating = ratings[movie.title]; + const rating = ratings[movie.title] if (typeof rating !== "number" || !Number.isInteger(rating)) - fail(400, "Each movie needs a rating"); - if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5"); + fail(400, "Each movie needs a rating") + if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5") await db .insert(votes) .values({ @@ -228,18 +246,18 @@ export async function voteMany( .onConflictDoUpdate({ target: [votes.roundUuid, votes.userName, votes.movieTitle], set: { rating }, - }); + }) } await db .update(roundUsers) .set({ donePhase2: false }) - .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))); - return loadState(uuid); + .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))) + return loadState(uuid) } function allDone(users: string[], doneUsers: string[]): boolean { - if (users.length === 0) return false; - return users.every((name) => doneUsers.includes(name)); + if (users.length === 0) return false + return users.every((name) => doneUsers.includes(name)) } export async function markDone( @@ -247,87 +265,87 @@ export async function markDone( user: string, phase: 1 | 2, ): Promise { - const state = await loadState(uuid); - if (!state.setupDone) fail(409, "Setup is not finished"); + const state = await loadState(uuid) + if (!state.setupDone) fail(409, "Setup is not finished") if (state.users.length > 0 && !state.users.includes(user)) - fail(403, "Unknown user"); + fail(403, "Unknown user") if (phase === 1) { - if (state.phase !== 1) fail(409, "Movie phase is not active"); + if (state.phase !== 1) fail(409, "Movie phase is not active") await db .update(roundUsers) .set({ donePhase1: true }) - .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))); - const updated = await loadState(uuid); + .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))) + const updated = await loadState(uuid) if (allDone(updated.users, updated.doneUsersPhase1)) { - await db.update(rounds).set({ phase: 2 }).where(eq(rounds.uuid, uuid)); + await db.update(rounds).set({ phase: 2 }).where(eq(rounds.uuid, uuid)) await db .update(roundUsers) .set({ donePhase2: false }) - .where(eq(roundUsers.roundUuid, uuid)); + .where(eq(roundUsers.roundUuid, uuid)) } - return loadState(uuid); + return loadState(uuid) } if (phase === 2) { - if (state.phase !== 2) fail(409, "Voting phase is not active"); - const movieTitles = state.movies.map((m) => m.title); - const userVotes = state.votes[user] ?? {}; + if (state.phase !== 2) fail(409, "Voting phase is not active") + const movieTitles = state.movies.map((m) => m.title) + const userVotes = state.votes[user] ?? {} for (const title of movieTitles) { if (typeof userVotes[title] !== "number") - fail(409, "Rate every movie before finishing"); + fail(409, "Rate every movie before finishing") } await db .update(roundUsers) .set({ donePhase2: true }) - .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))); - const updated = await loadState(uuid); + .where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user))) + const updated = await loadState(uuid) if (allDone(updated.users, updated.doneUsersPhase2)) { - await db.update(rounds).set({ phase: 3 }).where(eq(rounds.uuid, uuid)); + await db.update(rounds).set({ phase: 3 }).where(eq(rounds.uuid, uuid)) } - return loadState(uuid); + return loadState(uuid) } - fail(400, "Invalid done phase"); + fail(400, "Invalid done phase") } export async function setPhase(uuid: string, phase: 1 | 2 | 3): Promise { - const state = await loadState(uuid); - if (!state.setupDone) fail(409, "Finish setup first"); - await db.update(rounds).set({ phase }).where(eq(rounds.uuid, uuid)); + const state = await loadState(uuid) + if (!state.setupDone) fail(409, "Finish setup first") + await db.update(rounds).set({ phase }).where(eq(rounds.uuid, uuid)) if (phase === 1) { await db .update(roundUsers) .set({ donePhase1: false, donePhase2: false }) - .where(eq(roundUsers.roundUuid, uuid)); + .where(eq(roundUsers.roundUuid, uuid)) } if (phase === 2) { await db .update(roundUsers) .set({ donePhase2: false }) - .where(eq(roundUsers.roundUuid, uuid)); + .where(eq(roundUsers.roundUuid, uuid)) } - return loadState(uuid); + return loadState(uuid) } export async function newRound( uuid: string, rawWinner: string, ): Promise { - const state = await loadState(uuid); - if (state.phase !== 3) fail(409, "Round is not finished yet"); - const winner = rawWinner.trim().replace(TROPHY_RE, "") || null; + const state = await loadState(uuid) + if (state.phase !== 3) fail(409, "Round is not finished yet") + const winner = rawWinner.trim().replace(TROPHY_RE, "") || null await db.insert(roundHistory).values({ roundUuid: uuid, winner, moviesJson: JSON.stringify(state.movies), - }); - await db.delete(movies).where(eq(movies.roundUuid, uuid)); - await db.delete(votes).where(eq(votes.roundUuid, uuid)); - await db.update(rounds).set({ phase: 1 }).where(eq(rounds.uuid, uuid)); + }) + await db.delete(movies).where(eq(movies.roundUuid, uuid)) + await db.delete(votes).where(eq(votes.roundUuid, uuid)) + await db.update(rounds).set({ phase: 1 }).where(eq(rounds.uuid, uuid)) await db .update(roundUsers) .set({ donePhase1: false, donePhase2: false }) - .where(eq(roundUsers.roundUuid, uuid)); - return loadState(uuid); + .where(eq(roundUsers.roundUuid, uuid)) + return loadState(uuid) } diff --git a/src/server/index.ts b/src/server/index.ts index 006c569..32c0bba 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,7 +1,9 @@ -import { serve } from "@hono/node-server"; -import app from "./app.ts"; -import { env } from "./shared/lib/env.ts"; +import app from "./app.ts" +import { env } from "./shared/lib/env.ts" -serve({ fetch: app.fetch, port: env.PORT }, (info) => { - console.log(`Movie Select API listening on port ${info.port}`); -}); +console.log(`Movie Select API listening on port ${env.PORT}`) + +export default { + port: env.PORT, + fetch: app.fetch, +} diff --git a/src/server/shared/db/index.ts b/src/server/shared/db/index.ts index 790f4d8..a2ee53b 100644 --- a/src/server/shared/db/index.ts +++ b/src/server/shared/db/index.ts @@ -1,10 +1,10 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import { env } from "../lib/env.ts"; -import * as schema from "./schema/index.ts"; +import { drizzle } from "drizzle-orm/postgres-js" +import postgres from "postgres" +import { env } from "../lib/env.ts" +import * as schema from "./schema/index.ts" -const url = new URL(env.DATABASE_URL); -const socketHost = url.searchParams.get("host"); +const url = new URL(env.DATABASE_URL) +const socketHost = url.searchParams.get("host") const client = postgres({ host: socketHost ?? url.hostname, @@ -12,6 +12,6 @@ const client = postgres({ database: url.pathname.slice(1), username: url.username, password: url.password, -}); +}) -export const db = drizzle(client, { schema }); +export const db = drizzle(client, { schema }) diff --git a/src/server/shared/db/schema/index.ts b/src/server/shared/db/schema/index.ts index 820fe3d..167cfa4 100644 --- a/src/server/shared/db/schema/index.ts +++ b/src/server/shared/db/schema/index.ts @@ -1,5 +1,5 @@ -export { movies } from "./movies.ts"; -export { roundHistory } from "./round-history.ts"; -export { roundUsers } from "./round-users.ts"; -export { rounds } from "./rounds.ts"; -export { votes } from "./votes.ts"; +export { movies } from "./movies.ts" +export { roundHistory } from "./round-history.ts" +export { roundUsers } from "./round-users.ts" +export { rounds } from "./rounds.ts" +export { votes } from "./votes.ts" diff --git a/src/server/shared/db/schema/movies.ts b/src/server/shared/db/schema/movies.ts index 10e3cd3..6caa03c 100644 --- a/src/server/shared/db/schema/movies.ts +++ b/src/server/shared/db/schema/movies.ts @@ -5,8 +5,8 @@ import { timestamp, uniqueIndex, varchar, -} from "drizzle-orm/pg-core"; -import { rounds } from "./rounds.ts"; +} from "drizzle-orm/pg-core" +import { rounds } from "./rounds.ts" export const movies = pgTable( "movies", @@ -22,4 +22,4 @@ export const movies = pgTable( .defaultNow(), }, (t) => [uniqueIndex("uq_round_title").on(t.roundUuid, t.title)], -); +) diff --git a/src/server/shared/db/schema/round-history.ts b/src/server/shared/db/schema/round-history.ts index aa5a294..6bfeb30 100644 --- a/src/server/shared/db/schema/round-history.ts +++ b/src/server/shared/db/schema/round-history.ts @@ -5,8 +5,8 @@ import { text, timestamp, varchar, -} from "drizzle-orm/pg-core"; -import { rounds } from "./rounds.ts"; +} from "drizzle-orm/pg-core" +import { rounds } from "./rounds.ts" export const roundHistory = pgTable("round_history", { id: serial("id").primaryKey(), @@ -18,4 +18,4 @@ export const roundHistory = pgTable("round_history", { createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), -}); +}) diff --git a/src/server/shared/db/schema/round-users.ts b/src/server/shared/db/schema/round-users.ts index 3fa8528..ffc45a5 100644 --- a/src/server/shared/db/schema/round-users.ts +++ b/src/server/shared/db/schema/round-users.ts @@ -5,8 +5,8 @@ import { pgTable, primaryKey, varchar, -} from "drizzle-orm/pg-core"; -import { rounds } from "./rounds.ts"; +} from "drizzle-orm/pg-core" +import { rounds } from "./rounds.ts" export const roundUsers = pgTable( "round_users", @@ -20,4 +20,4 @@ export const roundUsers = pgTable( sortOrder: integer("sort_order").notNull().default(0), }, (t) => [primaryKey({ columns: [t.roundUuid, t.name] })], -); +) diff --git a/src/server/shared/db/schema/rounds.ts b/src/server/shared/db/schema/rounds.ts index 047fcb0..b45a579 100644 --- a/src/server/shared/db/schema/rounds.ts +++ b/src/server/shared/db/schema/rounds.ts @@ -4,10 +4,12 @@ import { pgTable, smallint, timestamp, -} from "drizzle-orm/pg-core"; + varchar, +} from "drizzle-orm/pg-core" export const rounds = pgTable("rounds", { uuid: char("uuid", { length: 36 }).primaryKey(), + name: varchar("name", { length: 60 }), phase: smallint("phase").notNull().default(1), setupDone: boolean("setup_done").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }) @@ -17,4 +19,4 @@ export const rounds = pgTable("rounds", { .notNull() .defaultNow() .$onUpdate(() => new Date()), -}); +}) diff --git a/src/server/shared/db/schema/votes.ts b/src/server/shared/db/schema/votes.ts index 199ce18..a1bc35b 100644 --- a/src/server/shared/db/schema/votes.ts +++ b/src/server/shared/db/schema/votes.ts @@ -4,8 +4,8 @@ import { primaryKey, smallint, varchar, -} from "drizzle-orm/pg-core"; -import { rounds } from "./rounds.ts"; +} from "drizzle-orm/pg-core" +import { rounds } from "./rounds.ts" export const votes = pgTable( "votes", @@ -18,4 +18,4 @@ export const votes = pgTable( rating: smallint("rating").notNull(), }, (t) => [primaryKey({ columns: [t.roundUuid, t.userName, t.movieTitle] })], -); +) diff --git a/src/server/shared/lib/env.ts b/src/server/shared/lib/env.ts index 2fbbd54..d01b5a9 100644 --- a/src/server/shared/lib/env.ts +++ b/src/server/shared/lib/env.ts @@ -1,8 +1,8 @@ -import { z } from "zod"; +import { z } from "zod" const envSchema = z.object({ DATABASE_URL: z.string().min(1), PORT: z.coerce.number().default(3001), -}); +}) -export const env = envSchema.parse(process.env); +export const env = envSchema.parse(process.env) diff --git a/src/shared/algorithm.ts b/src/shared/algorithm.ts index ba39991..8811f7b 100644 --- a/src/shared/algorithm.ts +++ b/src/shared/algorithm.ts @@ -5,21 +5,21 @@ export function paretoFilter( ): string[] { return movies.filter((movieA) => { return !movies.some((movieB) => { - if (movieA === movieB) return false; - let allAtLeastAsGood = true; - let strictlyBetter = false; + 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; + const a = ratings[person]?.[movieA] ?? 0 + const b = ratings[person]?.[movieB] ?? 0 if (b < a) { - allAtLeastAsGood = false; - break; + allAtLeastAsGood = false + break } - if (b > a) strictlyBetter = true; + if (b > a) strictlyBetter = true } - return allAtLeastAsGood && strictlyBetter; - }); - }); + return allAtLeastAsGood && strictlyBetter + }) + }) } export function nashScore( @@ -27,11 +27,11 @@ export function nashScore( people: string[], ratings: Record>, ): number { - let product = 1; + let product = 1 for (const person of people) { - product *= (ratings[person]?.[movie] ?? 0) + 1; + product *= (ratings[person]?.[movie] ?? 0) + 1 } - return product; + return product } export function averageScore( @@ -39,44 +39,44 @@ export function averageScore( people: string[], ratings: Record>, ): number { - let total = 0; + let total = 0 for (const person of people) { - total += ratings[person]?.[movie] ?? 0; + total += ratings[person]?.[movie] ?? 0 } - return total / people.length; + return total / people.length } export interface RankedMovie { - movie: string; - nash: number; - avg: number; + movie: string + nash: number + avg: number } export interface DecisionResult { - winner: RankedMovie; - remaining: string[]; - ranking: RankedMovie[]; + winner: RankedMovie + remaining: string[] + ranking: RankedMovie[] } export function decideMovie(opts: { - movies: string[]; - people: string[]; - ratings: Record>; + movies: string[] + people: string[] + ratings: Record> }): DecisionResult { - const { movies, people, ratings } = opts; + const { movies, people, ratings } = opts if (movies.length < 1 || people.length < 1) { - throw new Error("Need at least one movie and one person"); + throw new Error("Need at least one movie and one person") } - const remaining = paretoFilter(movies, people, ratings); + 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 }; + 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/shared/round-state.ts b/src/shared/round-state.ts index 289e002..c09d5f2 100644 --- a/src/shared/round-state.ts +++ b/src/shared/round-state.ts @@ -1,7 +1,7 @@ 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)); + return false + return users.every((name) => doneUsers.includes(name)) } export function hasCompleteRatings( @@ -13,11 +13,11 @@ export function hasCompleteRatings( !votesForUser || typeof votesForUser !== "object" ) - return false; + return false for (const title of movieTitles) { - if (!Number.isInteger(votesForUser[title])) return false; + if (!Number.isInteger(votesForUser[title])) return false } - return true; + return true } export function collectCompleteRatings( @@ -25,21 +25,21 @@ export function collectCompleteRatings( movieTitles: string[], votes: Record>, ): Record> { - const output: Record> = {}; + const output: Record> = {} if ( !Array.isArray(users) || !Array.isArray(movieTitles) || !votes || typeof votes !== "object" ) { - return output; + return output } for (const name of users) { - if (!hasCompleteRatings(movieTitles, votes[name])) continue; - output[name] = {}; + if (!hasCompleteRatings(movieTitles, votes[name])) continue + output[name] = {} for (const title of movieTitles) { - output[name][title] = votes[name]![title]!; + output[name][title] = votes[name]![title]! } } - return output; + return output } diff --git a/src/shared/types.ts b/src/shared/types.ts index 91642a7..271b48f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,24 +1,25 @@ export interface Movie { - title: string; - addedBy: string; - addedAt: string; + title: string + addedBy: string + addedAt: string } export interface HistoryEntry { - movies: Movie[]; - winner: string | null; + 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; + uuid: string + name: string | null + 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/tests/shared/algorithm.test.ts b/tests/shared/algorithm.test.ts index 42b1f98..abd5056 100644 --- a/tests/shared/algorithm.test.ts +++ b/tests/shared/algorithm.test.ts @@ -1,32 +1,32 @@ -import { expect, test } from "vitest"; -import { decideMovie, paretoFilter } from "../../src/shared/algorithm.ts"; +import { expect, test } from "vitest" +import { decideMovie, paretoFilter } from "../../src/shared/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"]); -}); + 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 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"); -}); + } + 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 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"); -}); + } + const result = decideMovie({ movies, people, ratings }) + expect(result.winner.movie).toBe("Alpha") +}) diff --git a/tests/shared/round-state.test.ts b/tests/shared/round-state.test.ts index e8d34e7..376e250 100644 --- a/tests/shared/round-state.test.ts +++ b/tests/shared/round-state.test.ts @@ -1,25 +1,25 @@ -import { expect, test } from "vitest"; +import { expect, test } from "vitest" import { allUsersDone, collectCompleteRatings, hasCompleteRatings, -} from "../../src/shared/round-state.ts"; +} from "../../src/shared/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); -}); + 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); -}); + 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 } }); -}); + }) + expect(result).toEqual({ A: { M1: 1, M2: 2 } }) +})