normalize biome config (lineWidth 80, quoteStyle double), switch server to bun native, add CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 23:01:11 +01:00
parent 08393cd7cd
commit 35fbb1b47d
40 changed files with 1053 additions and 547 deletions

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@ dist/
.DS_Store
data/
drizzle/meta/
.mise.local.toml
.env.local
.env.*.local

View File

@@ -1,3 +1,2 @@
[tools]
bun = "1.3.0"
node = "22"

52
CLAUDE.md Normal file
View File

@@ -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
```

View File

@@ -11,7 +11,14 @@
}
},
"formatter": {
"indentStyle": "tab"
"indentStyle": "tab",
"lineWidth": 80
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded"
}
},
"linter": {
"enabled": true,

View File

@@ -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=="],

View File

@@ -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' \

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f8fafc" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />

View File

@@ -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",

View File

@@ -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<State>) {
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<string, number>;
user: string
ratings: Record<string, number>
}) => 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),
});
})
}

View File

@@ -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,
});
})
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from "react"
export function useSwUpdate() {
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(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 }
}

View File

@@ -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);
}

View File

@@ -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 }),
};
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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(
<StrictMode>
<App />
</StrictMode>,
);
)

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
)
}

View File

@@ -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 <p className="mt-2 text-sm text-slate-500">Loading</p>;
return <p className="mt-2 text-sm text-slate-500">Loading</p>
}
if (!state.setupDone) {
return <AdminSetup uuid={uuid} state={state} />;
return <AdminSetup uuid={uuid} state={state} />
}
return <AdminStatus uuid={uuid} state={state} onRefresh={refetch} />;
return <AdminStatus uuid={uuid} state={state} onRefresh={refetch} />
}
function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const addUser = useAddUser(uuid);
const finishSetup = useFinishSetup(uuid);
const [draft, setDraft] = useState("")
const [nameDraft, setNameDraft] = useState(state.name ?? "")
const inputRef = useRef<HTMLInputElement>(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 (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Session Name{" "}
<span className="text-sm font-normal text-slate-400">· optional</span>
</h2>
<div className="mt-3">
<input
type="text"
maxLength={60}
placeholder="e.g. Friday Movie Night"
autoComplete="off"
spellCheck={false}
aria-label="Session name (optional)"
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)]"
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
;(e.target as HTMLInputElement).blur()
}
}}
/>
</div>
<h2 className="m-0 mt-5 text-xl font-bold tracking-tight text-slate-900">
Add People
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500">
@@ -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 }) {
</div>
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
{state.users.map((name) => (
<li
key={name}
className="inline-flex items-center rounded-full bg-slate-100 px-3 py-1.5 text-[13px] font-medium text-slate-700"
>
{name}
<li key={name}>
<button
type="button"
onClick={() => removeUser.mutate(name)}
disabled={removeUser.isPending}
className="inline-flex items-center rounded-full bg-slate-100 px-3 py-1.5 text-[13px] font-medium text-slate-700 cursor-pointer select-none border-0 transition-[background-color,transform] duration-100 hover:bg-red-100 hover:text-red-700 active:scale-[0.96]"
>
{name} ×
</button>
</li>
))}
</ul>
@@ -95,13 +148,13 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
Next
</button>
</div>
{(addUser.error || finishSetup.error) && (
{(addUser.error || removeUser.error || finishSetup.error) && (
<p className="mt-3 text-sm font-medium text-red-500">
{(addUser.error ?? finishSetup.error)?.message}
{(addUser.error ?? removeUser.error ?? finishSetup.error)?.message}
</p>
)}
</>
);
)
}
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({
</p>
<ul className="mt-3 flex flex-col gap-2 list-none p-0 m-0">
{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 (
<li
key={name}
@@ -178,25 +239,51 @@ function AdminStatus({
<span className="text-sm font-semibold">{name}</span>
<StatusBadge steps={steps} />
</div>
<div className="flex gap-2 items-center">
<input
className="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 flex-1 min-w-0"
type="text"
readOnly
value={link}
aria-label={`Invite link for ${name}`}
/>
<input
className="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 min-w-0"
type="text"
readOnly
value={link}
aria-label={`Invite link for ${name}`}
/>
<div className="mt-2 flex gap-2">
<button
type="button"
className="inline-flex items-center justify-center min-h-[36px] px-4 py-1.5 rounded-xl border border-slate-200 bg-white text-sm font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] shrink-0"
className="inline-flex items-center justify-center gap-1.5 min-h-[36px] flex-1 px-4 py-1.5 rounded-xl border border-slate-200 bg-white text-sm font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
onClick={(e) => handleCopy(link, e.currentTarget)}
aria-label={`Copy link for ${name}`}
>
Copy
</button>
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-1.5 min-h-[36px] flex-1 px-4 py-1.5 rounded-xl border border-slate-200 bg-white text-sm font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97] no-underline"
aria-label={`Open link for ${name}`}
>
Open
</a>
<button
type="button"
className="inline-flex items-center justify-center gap-1.5 min-h-[36px] flex-1 px-4 py-1.5 rounded-xl border border-slate-200 bg-white text-sm font-medium text-slate-800 whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
onClick={() => handleShare(link, name)}
aria-label={`Share link for ${name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="size-4"
aria-hidden="true"
>
<path d="M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z" />
</svg>
Share
</button>
</div>
</li>
);
)
})}
</ul>
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
@@ -224,7 +311,7 @@ function AdminStatus({
</>
)}
</>
);
)
}
function StatusBadge({ steps }: { steps: number }) {
@@ -233,18 +320,18 @@ function StatusBadge({ steps }: { steps: number }) {
<span className="ml-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700">
Done
</span>
);
)
if (steps === 1)
return (
<span className="ml-1.5 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
1/2
</span>
);
)
return (
<span className="ml-1.5 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">
0/2
</span>
);
)
}
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 (
<div className="mt-3 rounded-2xl border border-blue-200 bg-blue-50 p-4">
<p className="m-0 text-sm font-semibold text-blue-800">{title}</p>
@@ -272,5 +359,5 @@ function ProgressCard({
</span>
</div>
</div>
);
)
}

View File

@@ -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 (
<>
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
Home
</h2>
<div className="mt-4 flex flex-wrap justify-end gap-2.5">
<button
type="button"
onClick={handleCreate}
className="inline-flex items-center justify-center min-h-[44px] px-5 py-2.5 rounded-xl border border-blue-500 bg-blue-500 text-[15px] font-medium text-white whitespace-nowrap select-none transition-transform duration-100 active:scale-[0.97]"
>
New Round
</button>
</div>
{sessions.length > 0 && (
<>
<p className="m-0 mt-5 mb-2 text-[13px] font-semibold uppercase tracking-wider text-slate-400">
Recent Sessions
</p>
<ul className="mt-3 flex flex-col gap-2 list-none p-0 m-0">
{sessions.map((s) => (
<SessionRow key={s.uuid} session={s} />
))}
</ul>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={handleClear}
className="text-[13px] text-slate-400 hover:text-red-500 transition-colors border-0 bg-transparent cursor-pointer"
>
Clear history
</button>
</div>
</>
)}
</>
)
}
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 (
<li>
<Link
to={isAdmin ? "/$uuid/admin" : "/$uuid"}
params={{ uuid: session.uuid }}
search={isAdmin ? {} : { user: session.userName ?? "" }}
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white p-3.5 no-underline transition-colors hover:bg-slate-50"
>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
isAdmin
? "bg-blue-100 text-blue-700"
: "bg-green-100 text-green-700"
}`}
>
{isAdmin ? "Admin" : "User"}
</span>
<span className="flex-1 text-sm font-medium text-slate-800 truncate">
{session.sessionName ?? session.userName ?? session.uuid.slice(0, 8)}
</span>
<span className="shrink-0 text-[13px] text-slate-400">{dateStr}</span>
</Link>
</li>
)
}

View File

@@ -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<string, string>;
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<string, string>
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 <p className="mt-2 text-sm text-slate-500">Loading</p>;
return <p className="mt-2 text-sm text-slate-500">Loading</p>
}
if (!state.setupDone) {
@@ -31,11 +44,11 @@ export function UserRoute() {
<RefreshButton onRefresh={refetch} />
</div>
</>
);
)
}
if (!userName) {
return <AskUserName />;
return <AskUserName />
}
if (state.users.length > 0 && !state.users.includes(userName)) {
@@ -43,13 +56,13 @@ export function UserRoute() {
<p className="mt-3 text-sm font-medium text-red-500">
Unknown user use your invite link.
</p>
);
)
}
if (state.phase === 1) {
const isDone = state.doneUsersPhase1?.includes(userName);
const isDone = state.doneUsersPhase1?.includes(userName)
if (isDone) {
return <WaitingScreen state={state} phase={1} onRefresh={refetch} />;
return <WaitingScreen state={state} phase={1} onRefresh={refetch} />
}
return (
<MoviePhase
@@ -58,12 +71,12 @@ export function UserRoute() {
state={state}
onRefresh={refetch}
/>
);
)
}
if (state.phase === 2) {
const isDone = state.doneUsersPhase2?.includes(userName);
const isDone = state.doneUsersPhase2?.includes(userName)
if (isDone) {
return <WaitingScreen state={state} phase={2} onRefresh={refetch} />;
return <WaitingScreen state={state} phase={2} onRefresh={refetch} />
}
return (
<VotingPhase
@@ -72,19 +85,19 @@ export function UserRoute() {
state={state}
onRefresh={refetch}
/>
);
)
}
return <FinalPage state={state} onRefresh={refetch} />;
return <FinalPage state={state} onRefresh={refetch} />
}
function AskUserName() {
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(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()
}}
/>
<button
@@ -115,7 +128,7 @@ function AskUserName() {
</button>
</div>
</>
);
)
}
function WaitingScreen({
@@ -123,21 +136,21 @@ function WaitingScreen({
phase,
onRefresh,
}: {
state: State;
phase: 1 | 2;
onRefresh: () => void;
state: State
phase: 1 | 2
onRefresh: () => void
}) {
const d1 = state.doneUsersPhase1?.length ?? 0;
const d2 = state.doneUsersPhase2?.length ?? 0;
const total = state.users.length * 2;
const done = d1 + d2;
const d1 = state.doneUsersPhase1?.length ?? 0
const d2 = state.doneUsersPhase2?.length ?? 0
const total = state.users.length * 2
const done = d1 + d2
const phaseTitle =
phase === 1 ? "Phase 1 · Movie Collection" : "Phase 2 · Voting";
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…";
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
: "Your votes are saved. Waiting for others…"
const pct = total > 0 ? Math.round((done / total) * 100) : 0
return (
<>
@@ -163,7 +176,7 @@ function WaitingScreen({
<RefreshButton onRefresh={onRefresh} />
</div>
</>
);
)
}
function MoviePhase({
@@ -172,35 +185,35 @@ function MoviePhase({
state,
onRefresh,
}: {
uuid: string;
user: string;
state: State;
onRefresh: () => void;
uuid: string
user: string
state: State
onRefresh: () => void
}) {
const [movieDraft, setMovieDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const addMovie = useAddMovie(uuid);
const removeMovie = useRemoveMovie(uuid);
const markDone = useMarkDone(uuid);
const [movieDraft, setMovieDraft] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
const addMovie = useAddMovie(uuid)
const removeMovie = useRemoveMovie(uuid)
const markDone = useMarkDone(uuid)
const myMovies = state.movies.filter(
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
);
const remaining = Math.max(0, 5 - myMovies.length);
)
const remaining = Math.max(0, 5 - myMovies.length)
async function handleAdd(title?: string) {
const t = (title ?? movieDraft).trim();
if (!t) return;
await addMovie.mutateAsync({ user, title: t });
setMovieDraft("");
inputRef.current?.focus();
const t = (title ?? movieDraft).trim()
if (!t) return
await addMovie.mutateAsync({ user, title: t })
setMovieDraft("")
inputRef.current?.focus()
}
async function handleRemove(title: string) {
await removeMovie.mutateAsync({ user, title });
await removeMovie.mutateAsync({ user, title })
}
const historyItems = getHistoryItems(state);
const historyItems = getHistoryItems(state)
return (
<>
@@ -226,8 +239,8 @@ function MoviePhase({
onChange={(e) => setMovieDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
e.preventDefault()
handleAdd()
}
}}
/>
@@ -247,7 +260,7 @@ function MoviePhase({
</p>
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
{myMovies.map((m) => {
const clean = m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "");
const clean = m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "")
return (
<li key={m.title}>
<button
@@ -259,7 +272,7 @@ function MoviePhase({
{clean} ×
</button>
</li>
);
)
})}
</ul>
</>
@@ -298,7 +311,7 @@ function MoviePhase({
</div>
<ErrorMsg error={addMovie.error ?? removeMovie.error ?? markDone.error} />
</>
);
)
}
function VotingPhase({
@@ -307,35 +320,35 @@ function VotingPhase({
state,
onRefresh,
}: {
uuid: string;
user: string;
state: State;
onRefresh: () => void;
uuid: string
user: string
state: State
onRefresh: () => void
}) {
const movies = state.movies.map((m) => m.title);
const existingVotes = state.votes[user] ?? {};
const movies = state.movies.map((m) => m.title)
const existingVotes = state.votes[user] ?? {}
const [ratings, setRatings] = useState<Record<string, number>>(() => {
const initial: Record<string, number> = {};
const initial: Record<string, number> = {}
for (const title of movies) {
initial[title] = existingVotes[title] ?? 3;
initial[title] = existingVotes[title] ?? 3
}
return initial;
});
return initial
})
const vote = useVote(uuid);
const markDone = useMarkDone(uuid);
const vote = useVote(uuid)
const markDone = useMarkDone(uuid)
function handleRatingChange(title: string, value: number) {
setRatings((prev) => ({ ...prev, [title]: value }));
setRatings((prev) => ({ ...prev, [title]: value }))
}
async function handleSave() {
await vote.mutateAsync({ user, ratings });
await vote.mutateAsync({ user, ratings })
}
async function handleDone() {
await vote.mutateAsync({ user, ratings });
await markDone.mutateAsync({ user, phase: 2 });
await vote.mutateAsync({ user, ratings })
await markDone.mutateAsync({ user, phase: 2 })
}
return (
@@ -404,15 +417,15 @@ function VotingPhase({
</div>
<ErrorMsg error={vote.error ?? markDone.error} />
</>
);
)
}
function FinalPage({
state,
onRefresh,
}: {
state: State;
onRefresh: () => void;
state: State
onRefresh: () => void
}) {
return (
<>
@@ -427,17 +440,17 @@ function FinalPage({
<RefreshButton onRefresh={onRefresh} />
</div>
</>
);
)
}
export function ResultSection({
state,
title,
}: {
state: State;
title: string;
state: State
title: string
}) {
const movieTitles = state.movies.map((m) => m.title);
const movieTitles = state.movies.map((m) => m.title)
if (movieTitles.length === 0) {
return (
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
@@ -448,15 +461,15 @@ export function ResultSection({
No movies were added.
</p>
</div>
);
)
}
const ratings = collectCompleteRatings(
state.users,
movieTitles,
state.votes ?? {},
);
const voters = Object.keys(ratings);
)
const voters = Object.keys(ratings)
if (voters.length === 0) {
return (
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
@@ -467,14 +480,14 @@ export function ResultSection({
No complete votes yet.
</p>
</div>
);
)
}
const result = decideMovie({
movies: movieTitles,
people: voters,
ratings,
});
})
return (
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
@@ -504,7 +517,7 @@ export function ResultSection({
))}
</div>
</div>
);
)
}
function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
@@ -516,57 +529,57 @@ function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
>
Refresh
</button>
);
)
}
function ErrorMsg({ error }: { error: Error | null | undefined }) {
if (!error) return null;
if (!error) return null
return (
<p className="mt-3 text-sm font-medium text-red-500">{error.message}</p>
);
)
}
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<string>();
)
const wonMovies: string[] = []
const regularMovies = new Set<string>()
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<string>();
const wonList: Array<{ display: string; value: string }> = [];
const seenWon = new Set<string>()
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]
}

View File

@@ -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 (
<div className="min-h-dvh bg-slate-50 text-slate-900 font-sans antialiased">
<main className="mx-auto max-w-2xl px-4 pt-6 pb-10 flex flex-col gap-5 safe-b">
{updateAvailable && (
<div className="fixed top-0 inset-x-0 z-50 bg-blue-600 text-white text-sm text-center py-2.5 px-4 flex items-center justify-center gap-3">
<span>A new version is available.</span>
<button
type="button"
onClick={applyUpdate}
className="inline-flex items-center px-3 py-1 rounded-lg bg-white text-blue-600 text-sm font-semibold select-none transition-transform duration-100 active:scale-[0.97]"
>
Update
</button>
</div>
)}
<main
className={`mx-auto max-w-2xl px-4 pt-6 flex flex-col gap-5 ${uuid ? "pb-20" : "pb-10 safe-b"}`}
>
<header>
<h1 className="text-2xl font-bold tracking-tight">Movie Select</h1>
</header>
@@ -14,34 +44,83 @@ function RootLayout() {
<Outlet />
</section>
</main>
{uuid && (isAdmin || isHome) && (
<nav className="fixed bottom-0 inset-x-0 bg-white/95 backdrop-blur border-t border-slate-200 safe-b-tab z-50">
<div className="mx-auto max-w-2xl grid grid-cols-2">
<Link
to="/$uuid/home"
params={{ uuid }}
className={`flex flex-col items-center gap-1 py-2.5 text-xs no-underline transition-colors ${
isHome ? "text-blue-600 font-semibold" : "text-slate-400"
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="size-5"
aria-hidden="true"
>
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
Home
</Link>
<Link
to="/$uuid/admin"
params={{ uuid }}
className={`flex flex-col items-center gap-1 py-2.5 text-xs no-underline transition-colors ${
isAdmin ? "text-blue-600 font-semibold" : "text-slate-400"
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="size-5"
aria-hidden="true"
>
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
</svg>
Admin
</Link>
</div>
</nav>
)}
</div>
);
)
}
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,
]);
])

View File

@@ -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() {
</button>
</div>
</>
);
)
}

View File

@@ -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

View File

@@ -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 })
},
);
)

View File

@@ -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(""),
});
})

View File

@@ -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<State> {
@@ -35,17 +35,18 @@ export async function loadState(uuid: string): Promise<State> {
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<State> {
})
.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<State> {
})
.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<State> {
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<State> {
})
.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<string, Record<string, number>> = {};
const votesMap: Record<string, Record<string, number>> = {}
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<State> {
history,
createdAt: round?.createdAt?.toISOString() ?? new Date().toISOString(),
updatedAt: round?.updatedAt?.toISOString() ?? new Date().toISOString(),
};
}
}
export async function setName(uuid: string, name: string): Promise<State> {
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<State> {
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<State> {
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<State> {
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<State> {
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<State> {
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<string, number>,
): Promise<State> {
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<State> {
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<State> {
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<State> {
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)
}

View File

@@ -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,
}

View File

@@ -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 })

View File

@@ -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"

View File

@@ -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)],
);
)

View File

@@ -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(),
});
})

View File

@@ -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] })],
);
)

View File

@@ -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()),
});
})

View File

@@ -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] })],
);
)

View File

@@ -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)

View File

@@ -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<string, Record<string, number>>,
): 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<string, Record<string, number>>,
): 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<string, Record<string, number>>;
movies: string[]
people: string[]
ratings: Record<string, Record<string, number>>
}): 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 }
}

View File

@@ -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<string, Record<string, number>>,
): Record<string, Record<string, number>> {
const output: Record<string, Record<string, number>> = {};
const output: Record<string, Record<string, number>> = {}
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
}

View File

@@ -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<string, Record<string, number>>;
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<string, Record<string, number>>
history: HistoryEntry[]
createdAt: string
updatedAt: string
}

View File

@@ -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")
})

View File

@@ -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 } })
})