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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ dist/
|
||||
.DS_Store
|
||||
data/
|
||||
drizzle/meta/
|
||||
.mise.local.toml
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
[tools]
|
||||
bun = "1.3.0"
|
||||
node = "22"
|
||||
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal 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
|
||||
```
|
||||
@@ -11,7 +11,14 @@
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "tab"
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 80
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
4
bun.lock
4
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=="],
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
46
src/client/hooks/use-sw-update.ts
Normal file
46
src/client/hooks/use-sw-update.ts
Normal 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 }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
36
src/client/lib/sessions.ts
Normal file
36
src/client/lib/sessions.ts
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
99
src/client/routes/$uuid/home.tsx
Normal file
99
src/client/routes/$uuid/home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
])
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
@@ -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(""),
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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] })],
|
||||
);
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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] })],
|
||||
);
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user