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
|
.DS_Store
|
||||||
data/
|
data/
|
||||||
drizzle/meta/
|
drizzle/meta/
|
||||||
|
.mise.local.toml
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
[tools]
|
[tools]
|
||||||
bun = "1.3.0"
|
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": {
|
"formatter": {
|
||||||
"indentStyle": "tab"
|
"indentStyle": "tab",
|
||||||
|
"lineWidth": 80
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"semicolons": "asNeeded"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "movie-select",
|
"name": "movie-select",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
|
||||||
"@hono/zod-validator": "^0.7.6",
|
"@hono/zod-validator": "^0.7.6",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.92.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=="],
|
"@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=="],
|
"@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=="],
|
"@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)..."
|
echo "==> Building client (Vite)..."
|
||||||
bun run build
|
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} ..."
|
echo "==> Syncing static files to ${REMOTE_HOST}:${REMOTE_STATIC_DIR} ..."
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
--exclude='.DS_Store' \
|
--exclude='.DS_Store' \
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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="theme-color" content="#f8fafc" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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": "vite build",
|
||||||
"build:server": "true",
|
"build:server": "true",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
|
||||||
"@hono/zod-validator": "^0.7.6",
|
"@hono/zod-validator": "^0.7.6",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.92.0",
|
"@tanstack/react-router": "^1.92.0",
|
||||||
|
|||||||
@@ -1,82 +1,98 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import type { State } from "@/shared/types.ts";
|
import type { State } from "@/shared/types.ts"
|
||||||
import { api } from "../lib/api-client.ts";
|
import { api } from "../lib/api-client.ts"
|
||||||
|
|
||||||
function useRoundMutation(uuid: string, mutationFn: () => Promise<State>) {
|
function useRoundMutation(uuid: string, mutationFn: () => Promise<State>) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn,
|
mutationFn,
|
||||||
onSuccess: (state) => {
|
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) {
|
export function useAddUser(uuid: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (user: string) => api.addUser(uuid, user),
|
mutationFn: (user: string) => api.addUser(uuid, user),
|
||||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
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) {
|
export function useFinishSetup(uuid: string) {
|
||||||
return useRoundMutation(uuid, () => api.finishSetup(uuid));
|
return useRoundMutation(uuid, () => api.finishSetup(uuid))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAddMovie(uuid: string) {
|
export function useAddMovie(uuid: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ user, title }: { user: string; title: string }) =>
|
mutationFn: ({ user, title }: { user: string; title: string }) =>
|
||||||
api.addMovie(uuid, user, title),
|
api.addMovie(uuid, user, title),
|
||||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRemoveMovie(uuid: string) {
|
export function useRemoveMovie(uuid: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ user, title }: { user: string; title: string }) =>
|
mutationFn: ({ user, title }: { user: string; title: string }) =>
|
||||||
api.removeMovie(uuid, user, title),
|
api.removeMovie(uuid, user, title),
|
||||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVote(uuid: string) {
|
export function useVote(uuid: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
user,
|
user,
|
||||||
ratings,
|
ratings,
|
||||||
}: {
|
}: {
|
||||||
user: string;
|
user: string
|
||||||
ratings: Record<string, number>;
|
ratings: Record<string, number>
|
||||||
}) => api.vote(uuid, user, ratings),
|
}) => api.vote(uuid, user, ratings),
|
||||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMarkDone(uuid: string) {
|
export function useMarkDone(uuid: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ user, phase }: { user: string; phase: 1 | 2 }) =>
|
mutationFn: ({ user, phase }: { user: string; phase: 1 | 2 }) =>
|
||||||
api.markDone(uuid, user, phase),
|
api.markDone(uuid, user, phase),
|
||||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetPhase(uuid: string) {
|
export function useSetPhase(uuid: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (phase: 1 | 2 | 3) => api.setPhase(uuid, phase),
|
mutationFn: (phase: 1 | 2 | 3) => api.setPhase(uuid, phase),
|
||||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNewRound(uuid: string) {
|
export function useNewRound(uuid: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (winner: string) => api.newRound(uuid, winner),
|
mutationFn: (winner: string) => api.newRound(uuid, winner),
|
||||||
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
onSuccess: (state) => qc.setQueryData(["round", uuid], state),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { api } from "../lib/api-client.ts";
|
import { api } from "../lib/api-client.ts"
|
||||||
|
|
||||||
export function useRound(uuid: string) {
|
export function useRound(uuid: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -7,5 +7,5 @@ export function useRound(uuid: string) {
|
|||||||
queryFn: () => api.getState(uuid),
|
queryFn: () => api.getState(uuid),
|
||||||
enabled: uuid.length > 0,
|
enabled: uuid.length > 0,
|
||||||
refetchInterval: 5000,
|
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 {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
}
|
}
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
@@ -51,3 +52,8 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
.safe-b {
|
.safe-b {
|
||||||
padding-bottom: max(2.5rem, env(safe-area-inset-bottom));
|
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 {
|
interface ApiResponse {
|
||||||
ok: boolean;
|
ok: boolean
|
||||||
state: State;
|
state: State
|
||||||
error?: string;
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(
|
async function request(
|
||||||
@@ -17,16 +17,20 @@ async function request(
|
|||||||
method,
|
method,
|
||||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
})
|
||||||
const data = (await res.json()) as ApiResponse;
|
const data = (await res.json()) as ApiResponse
|
||||||
if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed");
|
if (!res.ok || !data.ok) throw new Error(data.error ?? "Request failed")
|
||||||
return data.state;
|
return data.state
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getState: (uuid: string) => request("GET", `/${uuid}`),
|
getState: (uuid: string) => request("GET", `/${uuid}`),
|
||||||
addUser: (uuid: string, user: string) =>
|
addUser: (uuid: string, user: string) =>
|
||||||
request("POST", `/${uuid}/users`, { user }),
|
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`),
|
finishSetup: (uuid: string) => request("POST", `/${uuid}/setup`),
|
||||||
addMovie: (uuid: string, user: string, title: string) =>
|
addMovie: (uuid: string, user: string, title: string) =>
|
||||||
request("POST", `/${uuid}/movies`, { user, title }),
|
request("POST", `/${uuid}/movies`, { user, title }),
|
||||||
@@ -40,4 +44,4 @@ export const api = {
|
|||||||
request("POST", `/${uuid}/phase`, { phase }),
|
request("POST", `/${uuid}/phase`, { phase }),
|
||||||
newRound: (uuid: string, winner: string) =>
|
newRound: (uuid: string, winner: string) =>
|
||||||
request("POST", `/${uuid}/new-round`, { winner }),
|
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 { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react"
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client"
|
||||||
import { App } from "./router.tsx";
|
import { App } from "./router.tsx"
|
||||||
import "./index.css";
|
import "./index.css"
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||||
import { routeTree } from "./routes/__root.tsx";
|
import { routeTree } from "./routes/__root.tsx"
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -8,17 +8,17 @@ const queryClient = new QueryClient({
|
|||||||
staleTime: 2000,
|
staleTime: 2000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
basepath: "/movie-select",
|
basepath: "/movie-select",
|
||||||
});
|
})
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,96 @@
|
|||||||
import { useParams } from "@tanstack/react-router";
|
import { useParams } from "@tanstack/react-router"
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { decideMovie } from "@/shared/algorithm.ts";
|
import { decideMovie } from "@/shared/algorithm.ts"
|
||||||
import { collectCompleteRatings } from "@/shared/round-state.ts";
|
import { collectCompleteRatings } from "@/shared/round-state.ts"
|
||||||
import type { State } from "@/shared/types.ts";
|
import type { State } from "@/shared/types.ts"
|
||||||
import { useRound } from "../../hooks/use-round.ts";
|
import { useRound } from "../../hooks/use-round.ts"
|
||||||
import {
|
import {
|
||||||
useAddUser,
|
useAddUser,
|
||||||
useFinishSetup,
|
useFinishSetup,
|
||||||
useNewRound,
|
useNewRound,
|
||||||
} from "../../hooks/use-round-mutation.ts";
|
useRemoveUser,
|
||||||
import { ResultSection } from "./route.tsx";
|
useSetName,
|
||||||
|
} from "../../hooks/use-round-mutation.ts"
|
||||||
|
import { saveSession } from "../../lib/sessions.ts"
|
||||||
|
import { ResultSection } from "./route.tsx"
|
||||||
|
|
||||||
export function AdminRoute() {
|
export function AdminRoute() {
|
||||||
const { uuid } = useParams({ strict: false }) as { uuid: string };
|
const { uuid } = useParams({ strict: false }) as { uuid: string }
|
||||||
const { data: state, refetch } = useRound(uuid);
|
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) {
|
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) {
|
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 }) {
|
function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("")
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const [nameDraft, setNameDraft] = useState(state.name ?? "")
|
||||||
const addUser = useAddUser(uuid);
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const finishSetup = useFinishSetup(uuid);
|
const setName = useSetName(uuid)
|
||||||
|
const addUser = useAddUser(uuid)
|
||||||
|
const removeUser = useRemoveUser(uuid)
|
||||||
|
const finishSetup = useFinishSetup(uuid)
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
const name = draft.trim();
|
const name = draft.trim()
|
||||||
if (!name) return;
|
if (!name) return
|
||||||
await addUser.mutateAsync(name);
|
await addUser.mutateAsync(name)
|
||||||
setDraft("");
|
setDraft("")
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNameBlur() {
|
||||||
|
const trimmed = nameDraft.trim()
|
||||||
|
if (trimmed !== (state.name ?? "")) {
|
||||||
|
setName.mutate(trimmed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="m-0 text-xl font-bold tracking-tight text-slate-900">
|
<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
|
Add People
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm leading-relaxed text-slate-500">
|
<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)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
handleAdd();
|
handleAdd()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -77,11 +126,15 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
|
|||||||
</div>
|
</div>
|
||||||
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
|
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
|
||||||
{state.users.map((name) => (
|
{state.users.map((name) => (
|
||||||
<li
|
<li key={name}>
|
||||||
key={name}
|
<button
|
||||||
className="inline-flex items-center rounded-full bg-slate-100 px-3 py-1.5 text-[13px] font-medium text-slate-700"
|
type="button"
|
||||||
>
|
onClick={() => removeUser.mutate(name)}
|
||||||
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -95,13 +148,13 @@ function AdminSetup({ uuid, state }: { uuid: string; state: State }) {
|
|||||||
Next →
|
Next →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{(addUser.error || finishSetup.error) && (
|
{(addUser.error || removeUser.error || finishSetup.error) && (
|
||||||
<p className="mt-3 text-sm font-medium text-red-500">
|
<p className="mt-3 text-sm font-medium text-red-500">
|
||||||
{(addUser.error ?? finishSetup.error)?.message}
|
{(addUser.error ?? removeUser.error ?? finishSetup.error)?.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminStatus({
|
function AdminStatus({
|
||||||
@@ -109,46 +162,54 @@ function AdminStatus({
|
|||||||
state,
|
state,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
uuid: string;
|
uuid: string
|
||||||
state: State;
|
state: State
|
||||||
onRefresh: () => void;
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const newRound = useNewRound(uuid);
|
const newRound = useNewRound(uuid)
|
||||||
const d1 = state.doneUsersPhase1 ?? [];
|
const d1 = state.doneUsersPhase1 ?? []
|
||||||
const d2 = state.doneUsersPhase2 ?? [];
|
const d2 = state.doneUsersPhase2 ?? []
|
||||||
const totalSteps = state.users.length * 2;
|
const totalSteps = state.users.length * 2
|
||||||
const completedSteps = d1.length + d2.length;
|
const completedSteps = d1.length + d2.length
|
||||||
const phaseTitle =
|
const phaseTitle =
|
||||||
state.phase === 1
|
state.phase === 1
|
||||||
? "Phase 1 · Movie Collection"
|
? "Phase 1 · Movie Collection"
|
||||||
: state.phase === 2
|
: state.phase === 2
|
||||||
? "Phase 2 · Voting"
|
? "Phase 2 · Voting"
|
||||||
: "Phase 3 · Results";
|
: "Phase 3 · Results"
|
||||||
|
|
||||||
function computeWinner(): string {
|
function computeWinner(): string {
|
||||||
const movieTitles = state.movies.map((m) => m.title);
|
const movieTitles = state.movies.map((m) => m.title)
|
||||||
if (movieTitles.length === 0) return "";
|
if (movieTitles.length === 0) return ""
|
||||||
const ratings = collectCompleteRatings(
|
const ratings = collectCompleteRatings(
|
||||||
state.users,
|
state.users,
|
||||||
movieTitles,
|
movieTitles,
|
||||||
state.votes ?? {},
|
state.votes ?? {},
|
||||||
);
|
)
|
||||||
const voters = Object.keys(ratings);
|
const voters = Object.keys(ratings)
|
||||||
if (voters.length === 0) return "";
|
if (voters.length === 0) return ""
|
||||||
return decideMovie({
|
return decideMovie({
|
||||||
movies: movieTitles,
|
movies: movieTitles,
|
||||||
people: voters,
|
people: voters,
|
||||||
ratings,
|
ratings,
|
||||||
}).winner.movie;
|
}).winner.movie
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCopy(link: string, btn: HTMLButtonElement) {
|
function handleCopy(link: string, btn: HTMLButtonElement) {
|
||||||
navigator.clipboard.writeText(link).then(() => {
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
btn.textContent = "✓ Copied";
|
btn.textContent = "✓ Copied"
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.textContent = "Copy";
|
btn.textContent = "Copy"
|
||||||
}, 1400);
|
}, 1400)
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShare(link: string, name: string) {
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({ title: `Movie Select — ${name}`, url: link })
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(link)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -166,9 +227,9 @@ function AdminStatus({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 flex flex-col gap-2 list-none p-0 m-0">
|
<ul className="mt-3 flex flex-col gap-2 list-none p-0 m-0">
|
||||||
{state.users.map((name) => {
|
{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 =
|
const steps =
|
||||||
(d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0);
|
(d1.includes(name) ? 1 : 0) + (d2.includes(name) ? 1 : 0)
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={name}
|
key={name}
|
||||||
@@ -178,25 +239,51 @@ function AdminStatus({
|
|||||||
<span className="text-sm font-semibold">{name}</span>
|
<span className="text-sm font-semibold">{name}</span>
|
||||||
<StatusBadge steps={steps} />
|
<StatusBadge steps={steps} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<input
|
||||||
<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"
|
||||||
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"
|
||||||
type="text"
|
readOnly
|
||||||
readOnly
|
value={link}
|
||||||
value={link}
|
aria-label={`Invite link for ${name}`}
|
||||||
aria-label={`Invite link for ${name}`}
|
/>
|
||||||
/>
|
<div className="mt-2 flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={(e) => handleCopy(link, e.currentTarget)}
|
||||||
aria-label={`Copy link for ${name}`}
|
aria-label={`Copy link for ${name}`}
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
<div className="mt-5 flex flex-wrap justify-end gap-2.5">
|
||||||
@@ -224,7 +311,7 @@ function AdminStatus({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ steps }: { steps: number }) {
|
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">
|
<span className="ml-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700">
|
||||||
Done
|
Done
|
||||||
</span>
|
</span>
|
||||||
);
|
)
|
||||||
if (steps === 1)
|
if (steps === 1)
|
||||||
return (
|
return (
|
||||||
<span className="ml-1.5 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
|
<span className="ml-1.5 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
|
||||||
1/2
|
1/2
|
||||||
</span>
|
</span>
|
||||||
);
|
)
|
||||||
return (
|
return (
|
||||||
<span className="ml-1.5 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">
|
<span className="ml-1.5 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-500">
|
||||||
0/2
|
0/2
|
||||||
</span>
|
</span>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressCard({
|
function ProgressCard({
|
||||||
@@ -252,11 +339,11 @@ function ProgressCard({
|
|||||||
done,
|
done,
|
||||||
total,
|
total,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string
|
||||||
done: number;
|
done: number
|
||||||
total: number;
|
total: number
|
||||||
}) {
|
}) {
|
||||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-2xl border border-blue-200 bg-blue-50 p-4">
|
<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>
|
<p className="m-0 text-sm font-semibold text-blue-800">{title}</p>
|
||||||
@@ -272,5 +359,5 @@ function ProgressCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 { useParams, useSearch } from "@tanstack/react-router"
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { decideMovie } from "@/shared/algorithm.ts";
|
import { decideMovie } from "@/shared/algorithm.ts"
|
||||||
import { collectCompleteRatings } from "@/shared/round-state.ts";
|
import { collectCompleteRatings } from "@/shared/round-state.ts"
|
||||||
import type { State } from "@/shared/types.ts";
|
import type { State } from "@/shared/types.ts"
|
||||||
import { useRound } from "../../hooks/use-round.ts";
|
import { useRound } from "../../hooks/use-round.ts"
|
||||||
import {
|
import {
|
||||||
useAddMovie,
|
useAddMovie,
|
||||||
useMarkDone,
|
useMarkDone,
|
||||||
useRemoveMovie,
|
useRemoveMovie,
|
||||||
useVote,
|
useVote,
|
||||||
} from "../../hooks/use-round-mutation.ts";
|
} from "../../hooks/use-round-mutation.ts"
|
||||||
|
import { saveSession } from "../../lib/sessions.ts"
|
||||||
|
|
||||||
export function UserRoute() {
|
export function UserRoute() {
|
||||||
const { uuid } = useParams({ strict: false }) as { uuid: string };
|
const { uuid } = useParams({ strict: false }) as { uuid: string }
|
||||||
const search = useSearch({ strict: false }) as Record<string, string>;
|
const search = useSearch({ strict: false }) as Record<string, string>
|
||||||
const userName = (search.user ?? "").trim();
|
const userName = (search.user ?? "").trim()
|
||||||
const { data: state, refetch } = useRound(uuid);
|
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) {
|
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) {
|
if (!state.setupDone) {
|
||||||
@@ -31,11 +44,11 @@ export function UserRoute() {
|
|||||||
<RefreshButton onRefresh={refetch} />
|
<RefreshButton onRefresh={refetch} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userName) {
|
if (!userName) {
|
||||||
return <AskUserName />;
|
return <AskUserName />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.users.length > 0 && !state.users.includes(userName)) {
|
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">
|
<p className="mt-3 text-sm font-medium text-red-500">
|
||||||
Unknown user — use your invite link.
|
Unknown user — use your invite link.
|
||||||
</p>
|
</p>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.phase === 1) {
|
if (state.phase === 1) {
|
||||||
const isDone = state.doneUsersPhase1?.includes(userName);
|
const isDone = state.doneUsersPhase1?.includes(userName)
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
return <WaitingScreen state={state} phase={1} onRefresh={refetch} />;
|
return <WaitingScreen state={state} phase={1} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<MoviePhase
|
<MoviePhase
|
||||||
@@ -58,12 +71,12 @@ export function UserRoute() {
|
|||||||
state={state}
|
state={state}
|
||||||
onRefresh={refetch}
|
onRefresh={refetch}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
if (state.phase === 2) {
|
if (state.phase === 2) {
|
||||||
const isDone = state.doneUsersPhase2?.includes(userName);
|
const isDone = state.doneUsersPhase2?.includes(userName)
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
return <WaitingScreen state={state} phase={2} onRefresh={refetch} />;
|
return <WaitingScreen state={state} phase={2} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<VotingPhase
|
<VotingPhase
|
||||||
@@ -72,19 +85,19 @@ export function UserRoute() {
|
|||||||
state={state}
|
state={state}
|
||||||
onRefresh={refetch}
|
onRefresh={refetch}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
return <FinalPage state={state} onRefresh={refetch} />;
|
return <FinalPage state={state} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function AskUserName() {
|
function AskUserName() {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const v = inputRef.current?.value.trim();
|
const v = inputRef.current?.value.trim()
|
||||||
if (!v) return;
|
if (!v) return
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search)
|
||||||
params.set("user", v);
|
params.set("user", v)
|
||||||
window.location.search = params.toString();
|
window.location.search = params.toString()
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -103,7 +116,7 @@ function AskUserName() {
|
|||||||
aria-label="Your name"
|
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"
|
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) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSubmit();
|
if (e.key === "Enter") handleSubmit()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -115,7 +128,7 @@ function AskUserName() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WaitingScreen({
|
function WaitingScreen({
|
||||||
@@ -123,21 +136,21 @@ function WaitingScreen({
|
|||||||
phase,
|
phase,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
state: State;
|
state: State
|
||||||
phase: 1 | 2;
|
phase: 1 | 2
|
||||||
onRefresh: () => void;
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const d1 = state.doneUsersPhase1?.length ?? 0;
|
const d1 = state.doneUsersPhase1?.length ?? 0
|
||||||
const d2 = state.doneUsersPhase2?.length ?? 0;
|
const d2 = state.doneUsersPhase2?.length ?? 0
|
||||||
const total = state.users.length * 2;
|
const total = state.users.length * 2
|
||||||
const done = d1 + d2;
|
const done = d1 + d2
|
||||||
const phaseTitle =
|
const phaseTitle =
|
||||||
phase === 1 ? "Phase 1 · Movie Collection" : "Phase 2 · Voting";
|
phase === 1 ? "Phase 1 · Movie Collection" : "Phase 2 · Voting"
|
||||||
const waitMsg =
|
const waitMsg =
|
||||||
phase === 1
|
phase === 1
|
||||||
? "Your movies are in. Waiting for others…"
|
? "Your movies are in. Waiting for others…"
|
||||||
: "Your votes are saved. Waiting for others…";
|
: "Your votes are saved. Waiting for others…"
|
||||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -163,7 +176,7 @@ function WaitingScreen({
|
|||||||
<RefreshButton onRefresh={onRefresh} />
|
<RefreshButton onRefresh={onRefresh} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoviePhase({
|
function MoviePhase({
|
||||||
@@ -172,35 +185,35 @@ function MoviePhase({
|
|||||||
state,
|
state,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
uuid: string;
|
uuid: string
|
||||||
user: string;
|
user: string
|
||||||
state: State;
|
state: State
|
||||||
onRefresh: () => void;
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const [movieDraft, setMovieDraft] = useState("");
|
const [movieDraft, setMovieDraft] = useState("")
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const addMovie = useAddMovie(uuid);
|
const addMovie = useAddMovie(uuid)
|
||||||
const removeMovie = useRemoveMovie(uuid);
|
const removeMovie = useRemoveMovie(uuid)
|
||||||
const markDone = useMarkDone(uuid);
|
const markDone = useMarkDone(uuid)
|
||||||
|
|
||||||
const myMovies = state.movies.filter(
|
const myMovies = state.movies.filter(
|
||||||
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
|
(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) {
|
async function handleAdd(title?: string) {
|
||||||
const t = (title ?? movieDraft).trim();
|
const t = (title ?? movieDraft).trim()
|
||||||
if (!t) return;
|
if (!t) return
|
||||||
await addMovie.mutateAsync({ user, title: t });
|
await addMovie.mutateAsync({ user, title: t })
|
||||||
setMovieDraft("");
|
setMovieDraft("")
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(title: string) {
|
async function handleRemove(title: string) {
|
||||||
await removeMovie.mutateAsync({ user, title });
|
await removeMovie.mutateAsync({ user, title })
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyItems = getHistoryItems(state);
|
const historyItems = getHistoryItems(state)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -226,8 +239,8 @@ function MoviePhase({
|
|||||||
onChange={(e) => setMovieDraft(e.target.value)}
|
onChange={(e) => setMovieDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
handleAdd();
|
handleAdd()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -247,7 +260,7 @@ function MoviePhase({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
|
<ul className="mt-3 flex flex-wrap gap-2 list-none p-0 m-0">
|
||||||
{myMovies.map((m) => {
|
{myMovies.map((m) => {
|
||||||
const clean = m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "");
|
const clean = m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "")
|
||||||
return (
|
return (
|
||||||
<li key={m.title}>
|
<li key={m.title}>
|
||||||
<button
|
<button
|
||||||
@@ -259,7 +272,7 @@ function MoviePhase({
|
|||||||
{clean} ×
|
{clean} ×
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
@@ -298,7 +311,7 @@ function MoviePhase({
|
|||||||
</div>
|
</div>
|
||||||
<ErrorMsg error={addMovie.error ?? removeMovie.error ?? markDone.error} />
|
<ErrorMsg error={addMovie.error ?? removeMovie.error ?? markDone.error} />
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function VotingPhase({
|
function VotingPhase({
|
||||||
@@ -307,35 +320,35 @@ function VotingPhase({
|
|||||||
state,
|
state,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
uuid: string;
|
uuid: string
|
||||||
user: string;
|
user: string
|
||||||
state: State;
|
state: State
|
||||||
onRefresh: () => void;
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const movies = state.movies.map((m) => m.title);
|
const movies = state.movies.map((m) => m.title)
|
||||||
const existingVotes = state.votes[user] ?? {};
|
const existingVotes = state.votes[user] ?? {}
|
||||||
const [ratings, setRatings] = useState<Record<string, number>>(() => {
|
const [ratings, setRatings] = useState<Record<string, number>>(() => {
|
||||||
const initial: Record<string, number> = {};
|
const initial: Record<string, number> = {}
|
||||||
for (const title of movies) {
|
for (const title of movies) {
|
||||||
initial[title] = existingVotes[title] ?? 3;
|
initial[title] = existingVotes[title] ?? 3
|
||||||
}
|
}
|
||||||
return initial;
|
return initial
|
||||||
});
|
})
|
||||||
|
|
||||||
const vote = useVote(uuid);
|
const vote = useVote(uuid)
|
||||||
const markDone = useMarkDone(uuid);
|
const markDone = useMarkDone(uuid)
|
||||||
|
|
||||||
function handleRatingChange(title: string, value: number) {
|
function handleRatingChange(title: string, value: number) {
|
||||||
setRatings((prev) => ({ ...prev, [title]: value }));
|
setRatings((prev) => ({ ...prev, [title]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
await vote.mutateAsync({ user, ratings });
|
await vote.mutateAsync({ user, ratings })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDone() {
|
async function handleDone() {
|
||||||
await vote.mutateAsync({ user, ratings });
|
await vote.mutateAsync({ user, ratings })
|
||||||
await markDone.mutateAsync({ user, phase: 2 });
|
await markDone.mutateAsync({ user, phase: 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -404,15 +417,15 @@ function VotingPhase({
|
|||||||
</div>
|
</div>
|
||||||
<ErrorMsg error={vote.error ?? markDone.error} />
|
<ErrorMsg error={vote.error ?? markDone.error} />
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FinalPage({
|
function FinalPage({
|
||||||
state,
|
state,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
state: State;
|
state: State
|
||||||
onRefresh: () => void;
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -427,17 +440,17 @@ function FinalPage({
|
|||||||
<RefreshButton onRefresh={onRefresh} />
|
<RefreshButton onRefresh={onRefresh} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultSection({
|
export function ResultSection({
|
||||||
state,
|
state,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
state: State;
|
state: State
|
||||||
title: string;
|
title: string
|
||||||
}) {
|
}) {
|
||||||
const movieTitles = state.movies.map((m) => m.title);
|
const movieTitles = state.movies.map((m) => m.title)
|
||||||
if (movieTitles.length === 0) {
|
if (movieTitles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
<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.
|
No movies were added.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ratings = collectCompleteRatings(
|
const ratings = collectCompleteRatings(
|
||||||
state.users,
|
state.users,
|
||||||
movieTitles,
|
movieTitles,
|
||||||
state.votes ?? {},
|
state.votes ?? {},
|
||||||
);
|
)
|
||||||
const voters = Object.keys(ratings);
|
const voters = Object.keys(ratings)
|
||||||
if (voters.length === 0) {
|
if (voters.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
<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.
|
No complete votes yet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = decideMovie({
|
const result = decideMovie({
|
||||||
movies: movieTitles,
|
movies: movieTitles,
|
||||||
people: voters,
|
people: voters,
|
||||||
ratings,
|
ratings,
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
<div className="mt-4 rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||||
@@ -504,7 +517,7 @@ export function ResultSection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
|
function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
|
||||||
@@ -516,57 +529,57 @@ function RefreshButton({ onRefresh }: { onRefresh: () => void }) {
|
|||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorMsg({ error }: { error: Error | null | undefined }) {
|
function ErrorMsg({ error }: { error: Error | null | undefined }) {
|
||||||
if (!error) return null;
|
if (!error) return null
|
||||||
return (
|
return (
|
||||||
<p className="mt-3 text-sm font-medium text-red-500">{error.message}</p>
|
<p className="mt-3 text-sm font-medium text-red-500">{error.message}</p>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHistoryItems(
|
function getHistoryItems(
|
||||||
state: State,
|
state: State,
|
||||||
): Array<{ display: string; value: string }> {
|
): Array<{ display: string; value: string }> {
|
||||||
const history = state.history ?? [];
|
const history = state.history ?? []
|
||||||
if (history.length === 0) return [];
|
if (history.length === 0) return []
|
||||||
|
|
||||||
const currentTitles = new Set(
|
const currentTitles = new Set(
|
||||||
state.movies.map((m) =>
|
state.movies.map((m) =>
|
||||||
m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "").toLowerCase(),
|
m.title.replace(/^(?:\u{1F3C6}\s*)+/u, "").toLowerCase(),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
const wonMovies: string[] = [];
|
const wonMovies: string[] = []
|
||||||
const regularMovies = new Set<string>();
|
const regularMovies = new Set<string>()
|
||||||
|
|
||||||
for (let i = history.length - 1; i >= 0; i--) {
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
const round = history[i]!;
|
const round = history[i]!
|
||||||
if (round.winner) wonMovies.push(round.winner);
|
if (round.winner) wonMovies.push(round.winner)
|
||||||
for (const m of round.movies ?? []) {
|
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(
|
const wonSet = new Set(
|
||||||
wonMovies.map((t) => t.replace(/^(?:\u{1F3C6}\s*)+/u, "").toLowerCase()),
|
wonMovies.map((t) => t.replace(/^(?:\u{1F3C6}\s*)+/u, "").toLowerCase()),
|
||||||
);
|
)
|
||||||
const regularList = [...regularMovies]
|
const regularList = [...regularMovies]
|
||||||
.filter(
|
.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
!wonSet.has(t.toLowerCase()) && !currentTitles.has(t.toLowerCase()),
|
!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 seenWon = new Set<string>()
|
||||||
const wonList: Array<{ display: string; value: string }> = [];
|
const wonList: Array<{ display: string; value: string }> = []
|
||||||
for (const w of wonMovies) {
|
for (const w of wonMovies) {
|
||||||
const clean = w.replace(/^(?:\u{1F3C6}\s*)+/u, "");
|
const clean = w.replace(/^(?:\u{1F3C6}\s*)+/u, "")
|
||||||
const key = clean.toLowerCase();
|
const key = clean.toLowerCase()
|
||||||
if (!clean || seenWon.has(key) || currentTitles.has(key)) continue;
|
if (!clean || seenWon.has(key) || currentTitles.has(key)) continue
|
||||||
seenWon.add(key);
|
seenWon.add(key)
|
||||||
wonList.push({ display: `🏆 ${clean}`, value: clean });
|
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 {
|
||||||
import { AdminRoute } from "./$uuid/admin.tsx";
|
createRootRoute,
|
||||||
import { UserRoute } from "./$uuid/route.tsx";
|
createRoute,
|
||||||
import { CreateScreen } from "./index.tsx";
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-dvh bg-slate-50 text-slate-900 font-sans antialiased">
|
<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>
|
<header>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Movie Select</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Movie Select</h1>
|
||||||
</header>
|
</header>
|
||||||
@@ -14,34 +44,83 @@ function RootLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
});
|
})
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/",
|
path: "/",
|
||||||
component: CreateScreen,
|
component: CreateScreen,
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const uuidHomeRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/$uuid/home",
|
||||||
|
component: HomeRoute,
|
||||||
|
})
|
||||||
|
|
||||||
const uuidAdminRoute = createRoute({
|
const uuidAdminRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/$uuid/admin",
|
path: "/$uuid/admin",
|
||||||
component: AdminRoute,
|
component: AdminRoute,
|
||||||
});
|
})
|
||||||
|
|
||||||
const uuidUserRoute = createRoute({
|
const uuidUserRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/$uuid",
|
path: "/$uuid",
|
||||||
component: UserRoute,
|
component: UserRoute,
|
||||||
});
|
})
|
||||||
|
|
||||||
export const routeTree = rootRoute.addChildren([
|
export const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
|
uuidHomeRoute,
|
||||||
uuidAdminRoute,
|
uuidAdminRoute,
|
||||||
uuidUserRoute,
|
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() {
|
export function CreateScreen() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID()
|
||||||
navigate({ to: "/$uuid/admin", params: { uuid } });
|
saveSession({ uuid, role: "admin", createdAt: new Date().toISOString() })
|
||||||
|
navigate({ to: "/$uuid/admin", params: { uuid } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,5 +28,5 @@ export function CreateScreen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono"
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors"
|
||||||
import { roundsRouter } from "./features/rounds/router.ts";
|
import { roundsRouter } from "./features/rounds/router.ts"
|
||||||
import { ApiError } from "./features/rounds/service.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) => {
|
app.onError((err, c) => {
|
||||||
if (err instanceof ApiError) {
|
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);
|
console.error(err)
|
||||||
return c.json({ ok: false, error: "Internal server error" }, 500);
|
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 { zValidator } from "@hono/zod-validator"
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono"
|
||||||
import {
|
import {
|
||||||
addMovieBody,
|
addMovieBody,
|
||||||
addUserBody,
|
addUserBody,
|
||||||
markDoneBody,
|
markDoneBody,
|
||||||
newRoundBody,
|
newRoundBody,
|
||||||
removeMovieBody,
|
removeMovieBody,
|
||||||
|
removeUserBody,
|
||||||
|
setNameBody,
|
||||||
setPhaseBody,
|
setPhaseBody,
|
||||||
uuidParam,
|
uuidParam,
|
||||||
voteBody,
|
voteBody,
|
||||||
} from "./schema.ts";
|
} from "./schema.ts"
|
||||||
import * as service from "./service.ts";
|
import * as service from "./service.ts"
|
||||||
|
|
||||||
export const roundsRouter = new Hono()
|
export const roundsRouter = new Hono()
|
||||||
.get("/:uuid", zValidator("param", uuidParam), async (c) => {
|
.get("/:uuid", zValidator("param", uuidParam), async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const state = await service.loadState(uuid);
|
const state = await service.loadState(uuid)
|
||||||
return c.json({ ok: true, state });
|
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(
|
.post(
|
||||||
"/:uuid/users",
|
"/:uuid/users",
|
||||||
zValidator("param", uuidParam),
|
zValidator("param", uuidParam),
|
||||||
zValidator("json", addUserBody),
|
zValidator("json", addUserBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const { user } = c.req.valid("json");
|
const { user } = c.req.valid("json")
|
||||||
const state = await service.addUser(uuid, user);
|
const state = await service.addUser(uuid, user)
|
||||||
return c.json({ ok: true, state });
|
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) => {
|
.post("/:uuid/setup", zValidator("param", uuidParam), async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const state = await service.finishSetup(uuid);
|
const state = await service.finishSetup(uuid)
|
||||||
return c.json({ ok: true, state });
|
return c.json({ ok: true, state })
|
||||||
})
|
})
|
||||||
|
|
||||||
.post(
|
.post(
|
||||||
@@ -42,10 +68,10 @@ export const roundsRouter = new Hono()
|
|||||||
zValidator("param", uuidParam),
|
zValidator("param", uuidParam),
|
||||||
zValidator("json", addMovieBody),
|
zValidator("json", addMovieBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const { user, title } = c.req.valid("json");
|
const { user, title } = c.req.valid("json")
|
||||||
const state = await service.addMovie(uuid, user, title);
|
const state = await service.addMovie(uuid, user, title)
|
||||||
return c.json({ ok: true, state });
|
return c.json({ ok: true, state })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,10 +80,10 @@ export const roundsRouter = new Hono()
|
|||||||
zValidator("param", uuidParam),
|
zValidator("param", uuidParam),
|
||||||
zValidator("json", removeMovieBody),
|
zValidator("json", removeMovieBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const { user, title } = c.req.valid("json");
|
const { user, title } = c.req.valid("json")
|
||||||
const state = await service.removeMovie(uuid, user, title);
|
const state = await service.removeMovie(uuid, user, title)
|
||||||
return c.json({ ok: true, state });
|
return c.json({ ok: true, state })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,10 +92,10 @@ export const roundsRouter = new Hono()
|
|||||||
zValidator("param", uuidParam),
|
zValidator("param", uuidParam),
|
||||||
zValidator("json", voteBody),
|
zValidator("json", voteBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const { user, ratings } = c.req.valid("json");
|
const { user, ratings } = c.req.valid("json")
|
||||||
const state = await service.voteMany(uuid, user, ratings);
|
const state = await service.voteMany(uuid, user, ratings)
|
||||||
return c.json({ ok: true, state });
|
return c.json({ ok: true, state })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,10 +104,10 @@ export const roundsRouter = new Hono()
|
|||||||
zValidator("param", uuidParam),
|
zValidator("param", uuidParam),
|
||||||
zValidator("json", markDoneBody),
|
zValidator("json", markDoneBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const { user, phase } = c.req.valid("json");
|
const { user, phase } = c.req.valid("json")
|
||||||
const state = await service.markDone(uuid, user, phase);
|
const state = await service.markDone(uuid, user, phase)
|
||||||
return c.json({ ok: true, state });
|
return c.json({ ok: true, state })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,10 +116,10 @@ export const roundsRouter = new Hono()
|
|||||||
zValidator("param", uuidParam),
|
zValidator("param", uuidParam),
|
||||||
zValidator("json", setPhaseBody),
|
zValidator("json", setPhaseBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const { phase } = c.req.valid("json");
|
const { phase } = c.req.valid("json")
|
||||||
const state = await service.setPhase(uuid, phase);
|
const state = await service.setPhase(uuid, phase)
|
||||||
return c.json({ ok: true, state });
|
return c.json({ ok: true, state })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,9 +128,9 @@ export const roundsRouter = new Hono()
|
|||||||
zValidator("param", uuidParam),
|
zValidator("param", uuidParam),
|
||||||
zValidator("json", newRoundBody),
|
zValidator("json", newRoundBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { uuid } = c.req.valid("param");
|
const { uuid } = c.req.valid("param")
|
||||||
const { winner } = c.req.valid("json");
|
const { winner } = c.req.valid("json")
|
||||||
const state = await service.newRound(uuid, winner);
|
const state = await service.newRound(uuid, winner)
|
||||||
return c.json({ ok: true, state });
|
return c.json({ ok: true, state })
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod"
|
||||||
|
|
||||||
export const uuidParam = z.object({
|
export const uuidParam = z.object({
|
||||||
uuid: z
|
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,
|
/^[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",
|
"Invalid UUID",
|
||||||
),
|
),
|
||||||
});
|
})
|
||||||
|
|
||||||
|
export const setNameBody = z.object({
|
||||||
|
name: z.string().trim().max(60, "Name too long"),
|
||||||
|
})
|
||||||
|
|
||||||
export const addUserBody = z.object({
|
export const addUserBody = z.object({
|
||||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
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({
|
export const addMovieBody = z.object({
|
||||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
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"),
|
title: z.string().trim().min(1, "Title required").max(60, "Title too long"),
|
||||||
});
|
})
|
||||||
|
|
||||||
export const removeMovieBody = z.object({
|
export const removeMovieBody = z.object({
|
||||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
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"),
|
title: z.string().trim().min(1, "Title required").max(60, "Title too long"),
|
||||||
});
|
})
|
||||||
|
|
||||||
export const voteBody = z.object({
|
export const voteBody = z.object({
|
||||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
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)),
|
ratings: z.record(z.string(), z.number().int().min(0).max(5)),
|
||||||
});
|
})
|
||||||
|
|
||||||
export const markDoneBody = z.object({
|
export const markDoneBody = z.object({
|
||||||
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
user: z.string().trim().min(1, "Name required").max(30, "Name too long"),
|
||||||
phase: z.union([z.literal(1), z.literal(2)]),
|
phase: z.union([z.literal(1), z.literal(2)]),
|
||||||
});
|
})
|
||||||
|
|
||||||
export const setPhaseBody = z.object({
|
export const setPhaseBody = z.object({
|
||||||
phase: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
phase: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||||
});
|
})
|
||||||
|
|
||||||
export const newRoundBody = z.object({
|
export const newRoundBody = z.object({
|
||||||
winner: z.string().default(""),
|
winner: z.string().default(""),
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
import type { Movie, State } from "@/shared/types.ts";
|
import type { Movie, State } from "@/shared/types.ts"
|
||||||
import { db } from "../../shared/db/index.ts";
|
import { db } from "../../shared/db/index.ts"
|
||||||
import {
|
import {
|
||||||
movies,
|
movies,
|
||||||
roundHistory,
|
roundHistory,
|
||||||
rounds,
|
rounds,
|
||||||
roundUsers,
|
roundUsers,
|
||||||
votes,
|
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 {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(status: number, message: string): never {
|
function fail(status: number, message: string): never {
|
||||||
throw new ApiError(status, message);
|
throw new ApiError(status, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanTitle(value: string): string {
|
function cleanTitle(value: string): string {
|
||||||
const trimmed = value.trim().replace(TROPHY_RE, "");
|
const trimmed = value.trim().replace(TROPHY_RE, "")
|
||||||
if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title");
|
if (trimmed === "" || trimmed.length > 60) fail(400, "Invalid movie title")
|
||||||
return trimmed;
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadState(uuid: string): Promise<State> {
|
export async function loadState(uuid: string): Promise<State> {
|
||||||
@@ -35,17 +35,18 @@ export async function loadState(uuid: string): Promise<State> {
|
|||||||
await db
|
await db
|
||||||
.insert(rounds)
|
.insert(rounds)
|
||||||
.values({ uuid })
|
.values({ uuid })
|
||||||
.onConflictDoNothing({ target: rounds.uuid });
|
.onConflictDoNothing({ target: rounds.uuid })
|
||||||
|
|
||||||
const [round] = await db
|
const [round] = await db
|
||||||
.select({
|
.select({
|
||||||
|
name: rounds.name,
|
||||||
phase: rounds.phase,
|
phase: rounds.phase,
|
||||||
setupDone: rounds.setupDone,
|
setupDone: rounds.setupDone,
|
||||||
createdAt: rounds.createdAt,
|
createdAt: rounds.createdAt,
|
||||||
updatedAt: rounds.updatedAt,
|
updatedAt: rounds.updatedAt,
|
||||||
})
|
})
|
||||||
.from(rounds)
|
.from(rounds)
|
||||||
.where(eq(rounds.uuid, uuid));
|
.where(eq(rounds.uuid, uuid))
|
||||||
|
|
||||||
const userRows = await db
|
const userRows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -55,7 +56,7 @@ export async function loadState(uuid: string): Promise<State> {
|
|||||||
})
|
})
|
||||||
.from(roundUsers)
|
.from(roundUsers)
|
||||||
.where(eq(roundUsers.roundUuid, uuid))
|
.where(eq(roundUsers.roundUuid, uuid))
|
||||||
.orderBy(asc(roundUsers.sortOrder), asc(roundUsers.name));
|
.orderBy(asc(roundUsers.sortOrder), asc(roundUsers.name))
|
||||||
|
|
||||||
const movieRows = await db
|
const movieRows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -65,7 +66,7 @@ export async function loadState(uuid: string): Promise<State> {
|
|||||||
})
|
})
|
||||||
.from(movies)
|
.from(movies)
|
||||||
.where(eq(movies.roundUuid, uuid))
|
.where(eq(movies.roundUuid, uuid))
|
||||||
.orderBy(asc(movies.id));
|
.orderBy(asc(movies.id))
|
||||||
|
|
||||||
const voteRows = await db
|
const voteRows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -74,7 +75,7 @@ export async function loadState(uuid: string): Promise<State> {
|
|||||||
rating: votes.rating,
|
rating: votes.rating,
|
||||||
})
|
})
|
||||||
.from(votes)
|
.from(votes)
|
||||||
.where(eq(votes.roundUuid, uuid));
|
.where(eq(votes.roundUuid, uuid))
|
||||||
|
|
||||||
const historyRows = await db
|
const historyRows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -83,27 +84,28 @@ export async function loadState(uuid: string): Promise<State> {
|
|||||||
})
|
})
|
||||||
.from(roundHistory)
|
.from(roundHistory)
|
||||||
.where(eq(roundHistory.roundUuid, uuid))
|
.where(eq(roundHistory.roundUuid, uuid))
|
||||||
.orderBy(asc(roundHistory.id));
|
.orderBy(asc(roundHistory.id))
|
||||||
|
|
||||||
const users = userRows.map((u) => u.name);
|
const users = userRows.map((u) => u.name)
|
||||||
const donePhase1 = userRows.filter((u) => u.donePhase1).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 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) {
|
for (const v of voteRows) {
|
||||||
if (!votesMap[v.userName]) votesMap[v.userName] = {};
|
if (!votesMap[v.userName]) votesMap[v.userName] = {}
|
||||||
votesMap[v.userName][v.movieTitle] = v.rating;
|
votesMap[v.userName][v.movieTitle] = v.rating
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = historyRows.map((h) => ({
|
const history = historyRows.map((h) => ({
|
||||||
winner: h.winner,
|
winner: h.winner,
|
||||||
movies: JSON.parse(h.moviesJson) as Movie[],
|
movies: JSON.parse(h.moviesJson) as Movie[],
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const phase = Number(round?.phase ?? 1);
|
const phase = Number(round?.phase ?? 1)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uuid,
|
uuid,
|
||||||
|
name: round?.name ?? null,
|
||||||
phase: (phase === 1 || phase === 2 || phase === 3 ? phase : 1) as 1 | 2 | 3,
|
phase: (phase === 1 || phase === 2 || phase === 3 ? phase : 1) as 1 | 2 | 3,
|
||||||
setupDone: Boolean(round?.setupDone),
|
setupDone: Boolean(round?.setupDone),
|
||||||
users,
|
users,
|
||||||
@@ -118,30 +120,46 @@ export async function loadState(uuid: string): Promise<State> {
|
|||||||
history,
|
history,
|
||||||
createdAt: round?.createdAt?.toISOString() ?? new Date().toISOString(),
|
createdAt: round?.createdAt?.toISOString() ?? new Date().toISOString(),
|
||||||
updatedAt: round?.updatedAt?.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> {
|
export async function addUser(uuid: string, user: string): Promise<State> {
|
||||||
const state = await loadState(uuid);
|
const state = await loadState(uuid)
|
||||||
if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed");
|
if (state.phase !== 1 || state.setupDone) fail(409, "Setup is closed")
|
||||||
if (!state.users.includes(user)) {
|
if (!state.users.includes(user)) {
|
||||||
await db
|
await db
|
||||||
.insert(roundUsers)
|
.insert(roundUsers)
|
||||||
.values({ roundUuid: uuid, name: user, sortOrder: state.users.length })
|
.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> {
|
export async function finishSetup(uuid: string): Promise<State> {
|
||||||
const state = await loadState(uuid);
|
const state = await loadState(uuid)
|
||||||
if (state.users.length < 1) fail(409, "Add at least one user first");
|
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(rounds).set({ setupDone: true }).where(eq(rounds.uuid, uuid))
|
||||||
await db
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase1: false, donePhase2: false })
|
.set({ donePhase1: false, donePhase2: false })
|
||||||
.where(eq(roundUsers.roundUuid, uuid));
|
.where(eq(roundUsers.roundUuid, uuid))
|
||||||
return loadState(uuid);
|
return loadState(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMovie(
|
export async function addMovie(
|
||||||
@@ -149,29 +167,29 @@ export async function addMovie(
|
|||||||
user: string,
|
user: string,
|
||||||
rawTitle: string,
|
rawTitle: string,
|
||||||
): Promise<State> {
|
): Promise<State> {
|
||||||
const state = await loadState(uuid);
|
const state = await loadState(uuid)
|
||||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
if (!state.setupDone) fail(409, "Setup is not finished")
|
||||||
if (state.phase !== 1) fail(409, "Movie phase is closed");
|
if (state.phase !== 1) fail(409, "Movie phase is closed")
|
||||||
if (state.users.length > 0 && !state.users.includes(user))
|
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(
|
const myCount = state.movies.filter(
|
||||||
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
|
(m) => m.addedBy.toLowerCase() === user.toLowerCase(),
|
||||||
).length;
|
).length
|
||||||
if (myCount >= 5) fail(409, "Movie limit reached for this user (5)");
|
if (myCount >= 5) fail(409, "Movie limit reached for this user (5)")
|
||||||
|
|
||||||
const duplicate = state.movies.some(
|
const duplicate = state.movies.some(
|
||||||
(m) => m.title.toLowerCase() === title.toLowerCase(),
|
(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
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase1: false })
|
.set({ donePhase1: false })
|
||||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)))
|
||||||
return loadState(uuid);
|
return loadState(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeMovie(
|
export async function removeMovie(
|
||||||
@@ -179,13 +197,13 @@ export async function removeMovie(
|
|||||||
user: string,
|
user: string,
|
||||||
rawTitle: string,
|
rawTitle: string,
|
||||||
): Promise<State> {
|
): Promise<State> {
|
||||||
const state = await loadState(uuid);
|
const state = await loadState(uuid)
|
||||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
if (!state.setupDone) fail(409, "Setup is not finished")
|
||||||
if (state.phase !== 1) fail(409, "Movie phase is closed");
|
if (state.phase !== 1) fail(409, "Movie phase is closed")
|
||||||
if (state.users.length > 0 && !state.users.includes(user))
|
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
|
await db
|
||||||
.delete(movies)
|
.delete(movies)
|
||||||
.where(
|
.where(
|
||||||
@@ -194,12 +212,12 @@ export async function removeMovie(
|
|||||||
eq(movies.title, title),
|
eq(movies.title, title),
|
||||||
eq(movies.addedBy, user),
|
eq(movies.addedBy, user),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
await db
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase1: false })
|
.set({ donePhase1: false })
|
||||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)))
|
||||||
return loadState(uuid);
|
return loadState(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function voteMany(
|
export async function voteMany(
|
||||||
@@ -207,16 +225,16 @@ export async function voteMany(
|
|||||||
user: string,
|
user: string,
|
||||||
ratings: Record<string, number>,
|
ratings: Record<string, number>,
|
||||||
): Promise<State> {
|
): Promise<State> {
|
||||||
const state = await loadState(uuid);
|
const state = await loadState(uuid)
|
||||||
if (state.phase !== 2) fail(409, "Voting phase is not active");
|
if (state.phase !== 2) fail(409, "Voting phase is not active")
|
||||||
if (state.users.length > 0 && !state.users.includes(user))
|
if (state.users.length > 0 && !state.users.includes(user))
|
||||||
fail(403, "Unknown user");
|
fail(403, "Unknown user")
|
||||||
|
|
||||||
for (const movie of state.movies) {
|
for (const movie of state.movies) {
|
||||||
const rating = ratings[movie.title];
|
const rating = ratings[movie.title]
|
||||||
if (typeof rating !== "number" || !Number.isInteger(rating))
|
if (typeof rating !== "number" || !Number.isInteger(rating))
|
||||||
fail(400, "Each movie needs a rating");
|
fail(400, "Each movie needs a rating")
|
||||||
if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5");
|
if (rating < 0 || rating > 5) fail(400, "Rating must be 0 to 5")
|
||||||
await db
|
await db
|
||||||
.insert(votes)
|
.insert(votes)
|
||||||
.values({
|
.values({
|
||||||
@@ -228,18 +246,18 @@ export async function voteMany(
|
|||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [votes.roundUuid, votes.userName, votes.movieTitle],
|
target: [votes.roundUuid, votes.userName, votes.movieTitle],
|
||||||
set: { rating },
|
set: { rating },
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
await db
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase2: false })
|
.set({ donePhase2: false })
|
||||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)))
|
||||||
return loadState(uuid);
|
return loadState(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
function allDone(users: string[], doneUsers: string[]): boolean {
|
function allDone(users: string[], doneUsers: string[]): boolean {
|
||||||
if (users.length === 0) return false;
|
if (users.length === 0) return false
|
||||||
return users.every((name) => doneUsers.includes(name));
|
return users.every((name) => doneUsers.includes(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markDone(
|
export async function markDone(
|
||||||
@@ -247,87 +265,87 @@ export async function markDone(
|
|||||||
user: string,
|
user: string,
|
||||||
phase: 1 | 2,
|
phase: 1 | 2,
|
||||||
): Promise<State> {
|
): Promise<State> {
|
||||||
const state = await loadState(uuid);
|
const state = await loadState(uuid)
|
||||||
if (!state.setupDone) fail(409, "Setup is not finished");
|
if (!state.setupDone) fail(409, "Setup is not finished")
|
||||||
if (state.users.length > 0 && !state.users.includes(user))
|
if (state.users.length > 0 && !state.users.includes(user))
|
||||||
fail(403, "Unknown user");
|
fail(403, "Unknown user")
|
||||||
|
|
||||||
if (phase === 1) {
|
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
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase1: true })
|
.set({ donePhase1: true })
|
||||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)))
|
||||||
const updated = await loadState(uuid);
|
const updated = await loadState(uuid)
|
||||||
if (allDone(updated.users, updated.doneUsersPhase1)) {
|
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
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase2: false })
|
.set({ donePhase2: false })
|
||||||
.where(eq(roundUsers.roundUuid, uuid));
|
.where(eq(roundUsers.roundUuid, uuid))
|
||||||
}
|
}
|
||||||
return loadState(uuid);
|
return loadState(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === 2) {
|
if (phase === 2) {
|
||||||
if (state.phase !== 2) fail(409, "Voting phase is not active");
|
if (state.phase !== 2) fail(409, "Voting phase is not active")
|
||||||
const movieTitles = state.movies.map((m) => m.title);
|
const movieTitles = state.movies.map((m) => m.title)
|
||||||
const userVotes = state.votes[user] ?? {};
|
const userVotes = state.votes[user] ?? {}
|
||||||
for (const title of movieTitles) {
|
for (const title of movieTitles) {
|
||||||
if (typeof userVotes[title] !== "number")
|
if (typeof userVotes[title] !== "number")
|
||||||
fail(409, "Rate every movie before finishing");
|
fail(409, "Rate every movie before finishing")
|
||||||
}
|
}
|
||||||
await db
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase2: true })
|
.set({ donePhase2: true })
|
||||||
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)));
|
.where(and(eq(roundUsers.roundUuid, uuid), eq(roundUsers.name, user)))
|
||||||
const updated = await loadState(uuid);
|
const updated = await loadState(uuid)
|
||||||
if (allDone(updated.users, updated.doneUsersPhase2)) {
|
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> {
|
export async function setPhase(uuid: string, phase: 1 | 2 | 3): Promise<State> {
|
||||||
const state = await loadState(uuid);
|
const state = await loadState(uuid)
|
||||||
if (!state.setupDone) fail(409, "Finish setup first");
|
if (!state.setupDone) fail(409, "Finish setup first")
|
||||||
await db.update(rounds).set({ phase }).where(eq(rounds.uuid, uuid));
|
await db.update(rounds).set({ phase }).where(eq(rounds.uuid, uuid))
|
||||||
if (phase === 1) {
|
if (phase === 1) {
|
||||||
await db
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase1: false, donePhase2: false })
|
.set({ donePhase1: false, donePhase2: false })
|
||||||
.where(eq(roundUsers.roundUuid, uuid));
|
.where(eq(roundUsers.roundUuid, uuid))
|
||||||
}
|
}
|
||||||
if (phase === 2) {
|
if (phase === 2) {
|
||||||
await db
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase2: false })
|
.set({ donePhase2: false })
|
||||||
.where(eq(roundUsers.roundUuid, uuid));
|
.where(eq(roundUsers.roundUuid, uuid))
|
||||||
}
|
}
|
||||||
return loadState(uuid);
|
return loadState(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function newRound(
|
export async function newRound(
|
||||||
uuid: string,
|
uuid: string,
|
||||||
rawWinner: string,
|
rawWinner: string,
|
||||||
): Promise<State> {
|
): Promise<State> {
|
||||||
const state = await loadState(uuid);
|
const state = await loadState(uuid)
|
||||||
if (state.phase !== 3) fail(409, "Round is not finished yet");
|
if (state.phase !== 3) fail(409, "Round is not finished yet")
|
||||||
const winner = rawWinner.trim().replace(TROPHY_RE, "") || null;
|
const winner = rawWinner.trim().replace(TROPHY_RE, "") || null
|
||||||
await db.insert(roundHistory).values({
|
await db.insert(roundHistory).values({
|
||||||
roundUuid: uuid,
|
roundUuid: uuid,
|
||||||
winner,
|
winner,
|
||||||
moviesJson: JSON.stringify(state.movies),
|
moviesJson: JSON.stringify(state.movies),
|
||||||
});
|
})
|
||||||
await db.delete(movies).where(eq(movies.roundUuid, uuid));
|
await db.delete(movies).where(eq(movies.roundUuid, uuid))
|
||||||
await db.delete(votes).where(eq(votes.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(rounds).set({ phase: 1 }).where(eq(rounds.uuid, uuid))
|
||||||
await db
|
await db
|
||||||
.update(roundUsers)
|
.update(roundUsers)
|
||||||
.set({ donePhase1: false, donePhase2: false })
|
.set({ donePhase1: false, donePhase2: false })
|
||||||
.where(eq(roundUsers.roundUuid, uuid));
|
.where(eq(roundUsers.roundUuid, uuid))
|
||||||
return loadState(uuid);
|
return loadState(uuid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { serve } from "@hono/node-server";
|
import app from "./app.ts"
|
||||||
import app from "./app.ts";
|
import { env } from "./shared/lib/env.ts"
|
||||||
import { env } from "./shared/lib/env.ts";
|
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: env.PORT }, (info) => {
|
console.log(`Movie Select API listening on port ${env.PORT}`)
|
||||||
console.log(`Movie Select API listening on port ${info.port}`);
|
|
||||||
});
|
export default {
|
||||||
|
port: env.PORT,
|
||||||
|
fetch: app.fetch,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js"
|
||||||
import postgres from "postgres";
|
import postgres from "postgres"
|
||||||
import { env } from "../lib/env.ts";
|
import { env } from "../lib/env.ts"
|
||||||
import * as schema from "./schema/index.ts";
|
import * as schema from "./schema/index.ts"
|
||||||
|
|
||||||
const url = new URL(env.DATABASE_URL);
|
const url = new URL(env.DATABASE_URL)
|
||||||
const socketHost = url.searchParams.get("host");
|
const socketHost = url.searchParams.get("host")
|
||||||
|
|
||||||
const client = postgres({
|
const client = postgres({
|
||||||
host: socketHost ?? url.hostname,
|
host: socketHost ?? url.hostname,
|
||||||
@@ -12,6 +12,6 @@ const client = postgres({
|
|||||||
database: url.pathname.slice(1),
|
database: url.pathname.slice(1),
|
||||||
username: url.username,
|
username: url.username,
|
||||||
password: url.password,
|
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 { movies } from "./movies.ts"
|
||||||
export { roundHistory } from "./round-history.ts";
|
export { roundHistory } from "./round-history.ts"
|
||||||
export { roundUsers } from "./round-users.ts";
|
export { roundUsers } from "./round-users.ts"
|
||||||
export { rounds } from "./rounds.ts";
|
export { rounds } from "./rounds.ts"
|
||||||
export { votes } from "./votes.ts";
|
export { votes } from "./votes.ts"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core"
|
||||||
import { rounds } from "./rounds.ts";
|
import { rounds } from "./rounds.ts"
|
||||||
|
|
||||||
export const movies = pgTable(
|
export const movies = pgTable(
|
||||||
"movies",
|
"movies",
|
||||||
@@ -22,4 +22,4 @@ export const movies = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [uniqueIndex("uq_round_title").on(t.roundUuid, t.title)],
|
(t) => [uniqueIndex("uq_round_title").on(t.roundUuid, t.title)],
|
||||||
);
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core"
|
||||||
import { rounds } from "./rounds.ts";
|
import { rounds } from "./rounds.ts"
|
||||||
|
|
||||||
export const roundHistory = pgTable("round_history", {
|
export const roundHistory = pgTable("round_history", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
@@ -18,4 +18,4 @@ export const roundHistory = pgTable("round_history", {
|
|||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core"
|
||||||
import { rounds } from "./rounds.ts";
|
import { rounds } from "./rounds.ts"
|
||||||
|
|
||||||
export const roundUsers = pgTable(
|
export const roundUsers = pgTable(
|
||||||
"round_users",
|
"round_users",
|
||||||
@@ -20,4 +20,4 @@ export const roundUsers = pgTable(
|
|||||||
sortOrder: integer("sort_order").notNull().default(0),
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.roundUuid, t.name] })],
|
(t) => [primaryKey({ columns: [t.roundUuid, t.name] })],
|
||||||
);
|
)
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
smallint,
|
smallint,
|
||||||
timestamp,
|
timestamp,
|
||||||
} from "drizzle-orm/pg-core";
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
export const rounds = pgTable("rounds", {
|
export const rounds = pgTable("rounds", {
|
||||||
uuid: char("uuid", { length: 36 }).primaryKey(),
|
uuid: char("uuid", { length: 36 }).primaryKey(),
|
||||||
|
name: varchar("name", { length: 60 }),
|
||||||
phase: smallint("phase").notNull().default(1),
|
phase: smallint("phase").notNull().default(1),
|
||||||
setupDone: boolean("setup_done").notNull().default(false),
|
setupDone: boolean("setup_done").notNull().default(false),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
@@ -17,4 +19,4 @@ export const rounds = pgTable("rounds", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => new Date()),
|
.$onUpdate(() => new Date()),
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
primaryKey,
|
primaryKey,
|
||||||
smallint,
|
smallint,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core"
|
||||||
import { rounds } from "./rounds.ts";
|
import { rounds } from "./rounds.ts"
|
||||||
|
|
||||||
export const votes = pgTable(
|
export const votes = pgTable(
|
||||||
"votes",
|
"votes",
|
||||||
@@ -18,4 +18,4 @@ export const votes = pgTable(
|
|||||||
rating: smallint("rating").notNull(),
|
rating: smallint("rating").notNull(),
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.roundUuid, t.userName, t.movieTitle] })],
|
(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({
|
const envSchema = z.object({
|
||||||
DATABASE_URL: z.string().min(1),
|
DATABASE_URL: z.string().min(1),
|
||||||
PORT: z.coerce.number().default(3001),
|
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[] {
|
): string[] {
|
||||||
return movies.filter((movieA) => {
|
return movies.filter((movieA) => {
|
||||||
return !movies.some((movieB) => {
|
return !movies.some((movieB) => {
|
||||||
if (movieA === movieB) return false;
|
if (movieA === movieB) return false
|
||||||
let allAtLeastAsGood = true;
|
let allAtLeastAsGood = true
|
||||||
let strictlyBetter = false;
|
let strictlyBetter = false
|
||||||
for (const person of people) {
|
for (const person of people) {
|
||||||
const a = ratings[person]?.[movieA] ?? 0;
|
const a = ratings[person]?.[movieA] ?? 0
|
||||||
const b = ratings[person]?.[movieB] ?? 0;
|
const b = ratings[person]?.[movieB] ?? 0
|
||||||
if (b < a) {
|
if (b < a) {
|
||||||
allAtLeastAsGood = false;
|
allAtLeastAsGood = false
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
if (b > a) strictlyBetter = true;
|
if (b > a) strictlyBetter = true
|
||||||
}
|
}
|
||||||
return allAtLeastAsGood && strictlyBetter;
|
return allAtLeastAsGood && strictlyBetter
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nashScore(
|
export function nashScore(
|
||||||
@@ -27,11 +27,11 @@ export function nashScore(
|
|||||||
people: string[],
|
people: string[],
|
||||||
ratings: Record<string, Record<string, number>>,
|
ratings: Record<string, Record<string, number>>,
|
||||||
): number {
|
): number {
|
||||||
let product = 1;
|
let product = 1
|
||||||
for (const person of people) {
|
for (const person of people) {
|
||||||
product *= (ratings[person]?.[movie] ?? 0) + 1;
|
product *= (ratings[person]?.[movie] ?? 0) + 1
|
||||||
}
|
}
|
||||||
return product;
|
return product
|
||||||
}
|
}
|
||||||
|
|
||||||
export function averageScore(
|
export function averageScore(
|
||||||
@@ -39,44 +39,44 @@ export function averageScore(
|
|||||||
people: string[],
|
people: string[],
|
||||||
ratings: Record<string, Record<string, number>>,
|
ratings: Record<string, Record<string, number>>,
|
||||||
): number {
|
): number {
|
||||||
let total = 0;
|
let total = 0
|
||||||
for (const person of people) {
|
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 {
|
export interface RankedMovie {
|
||||||
movie: string;
|
movie: string
|
||||||
nash: number;
|
nash: number
|
||||||
avg: number;
|
avg: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecisionResult {
|
export interface DecisionResult {
|
||||||
winner: RankedMovie;
|
winner: RankedMovie
|
||||||
remaining: string[];
|
remaining: string[]
|
||||||
ranking: RankedMovie[];
|
ranking: RankedMovie[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decideMovie(opts: {
|
export function decideMovie(opts: {
|
||||||
movies: string[];
|
movies: string[]
|
||||||
people: string[];
|
people: string[]
|
||||||
ratings: Record<string, Record<string, number>>;
|
ratings: Record<string, Record<string, number>>
|
||||||
}): DecisionResult {
|
}): DecisionResult {
|
||||||
const { movies, people, ratings } = opts;
|
const { movies, people, ratings } = opts
|
||||||
if (movies.length < 1 || people.length < 1) {
|
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) => ({
|
const scored: RankedMovie[] = remaining.map((movie) => ({
|
||||||
movie,
|
movie,
|
||||||
nash: nashScore(movie, people, ratings),
|
nash: nashScore(movie, people, ratings),
|
||||||
avg: averageScore(movie, people, ratings),
|
avg: averageScore(movie, people, ratings),
|
||||||
}));
|
}))
|
||||||
scored.sort((a, b) => {
|
scored.sort((a, b) => {
|
||||||
if (b.nash !== a.nash) return b.nash - a.nash;
|
if (b.nash !== a.nash) return b.nash - a.nash
|
||||||
if (b.avg !== a.avg) return b.avg - a.avg;
|
if (b.avg !== a.avg) return b.avg - a.avg
|
||||||
return a.movie.localeCompare(b.movie);
|
return a.movie.localeCompare(b.movie)
|
||||||
});
|
})
|
||||||
return { winner: scored[0]!, remaining, ranking: scored };
|
return { winner: scored[0]!, remaining, ranking: scored }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export function allUsersDone(users: string[], doneUsers: string[]): boolean {
|
export function allUsersDone(users: string[], doneUsers: string[]): boolean {
|
||||||
if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers))
|
if (!Array.isArray(users) || users.length === 0 || !Array.isArray(doneUsers))
|
||||||
return false;
|
return false
|
||||||
return users.every((name) => doneUsers.includes(name));
|
return users.every((name) => doneUsers.includes(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasCompleteRatings(
|
export function hasCompleteRatings(
|
||||||
@@ -13,11 +13,11 @@ export function hasCompleteRatings(
|
|||||||
!votesForUser ||
|
!votesForUser ||
|
||||||
typeof votesForUser !== "object"
|
typeof votesForUser !== "object"
|
||||||
)
|
)
|
||||||
return false;
|
return false
|
||||||
for (const title of movieTitles) {
|
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(
|
export function collectCompleteRatings(
|
||||||
@@ -25,21 +25,21 @@ export function collectCompleteRatings(
|
|||||||
movieTitles: string[],
|
movieTitles: string[],
|
||||||
votes: Record<string, Record<string, number>>,
|
votes: Record<string, Record<string, number>>,
|
||||||
): 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 (
|
if (
|
||||||
!Array.isArray(users) ||
|
!Array.isArray(users) ||
|
||||||
!Array.isArray(movieTitles) ||
|
!Array.isArray(movieTitles) ||
|
||||||
!votes ||
|
!votes ||
|
||||||
typeof votes !== "object"
|
typeof votes !== "object"
|
||||||
) {
|
) {
|
||||||
return output;
|
return output
|
||||||
}
|
}
|
||||||
for (const name of users) {
|
for (const name of users) {
|
||||||
if (!hasCompleteRatings(movieTitles, votes[name])) continue;
|
if (!hasCompleteRatings(movieTitles, votes[name])) continue
|
||||||
output[name] = {};
|
output[name] = {}
|
||||||
for (const title of movieTitles) {
|
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 {
|
export interface Movie {
|
||||||
title: string;
|
title: string
|
||||||
addedBy: string;
|
addedBy: string
|
||||||
addedAt: string;
|
addedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
movies: Movie[];
|
movies: Movie[]
|
||||||
winner: string | null;
|
winner: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
uuid: string;
|
uuid: string
|
||||||
phase: 1 | 2 | 3;
|
name: string | null
|
||||||
setupDone: boolean;
|
phase: 1 | 2 | 3
|
||||||
users: string[];
|
setupDone: boolean
|
||||||
doneUsersPhase1: string[];
|
users: string[]
|
||||||
doneUsersPhase2: string[];
|
doneUsersPhase1: string[]
|
||||||
movies: Movie[];
|
doneUsersPhase2: string[]
|
||||||
votes: Record<string, Record<string, number>>;
|
movies: Movie[]
|
||||||
history: HistoryEntry[];
|
votes: Record<string, Record<string, number>>
|
||||||
createdAt: string;
|
history: HistoryEntry[]
|
||||||
updatedAt: string;
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest"
|
||||||
import { decideMovie, paretoFilter } from "../../src/shared/algorithm.ts";
|
import { decideMovie, paretoFilter } from "../../src/shared/algorithm.ts"
|
||||||
|
|
||||||
test("paretoFilter removes dominated movies", () => {
|
test("paretoFilter removes dominated movies", () => {
|
||||||
const movies = ["A", "B"];
|
const movies = ["A", "B"]
|
||||||
const people = ["P1", "P2"];
|
const people = ["P1", "P2"]
|
||||||
const ratings = { P1: { A: 1, B: 3 }, P2: { A: 2, B: 4 } };
|
const ratings = { P1: { A: 1, B: 3 }, P2: { A: 2, B: 4 } }
|
||||||
expect(paretoFilter(movies, people, ratings)).toEqual(["B"]);
|
expect(paretoFilter(movies, people, ratings)).toEqual(["B"])
|
||||||
});
|
})
|
||||||
|
|
||||||
test("nash protects against hard no", () => {
|
test("nash protects against hard no", () => {
|
||||||
const movies = ["Consensus", "Polarizing"];
|
const movies = ["Consensus", "Polarizing"]
|
||||||
const people = ["A", "B", "C"];
|
const people = ["A", "B", "C"]
|
||||||
const ratings = {
|
const ratings = {
|
||||||
A: { Consensus: 3, Polarizing: 5 },
|
A: { Consensus: 3, Polarizing: 5 },
|
||||||
B: { Consensus: 3, Polarizing: 5 },
|
B: { Consensus: 3, Polarizing: 5 },
|
||||||
C: { Consensus: 3, Polarizing: 0 },
|
C: { Consensus: 3, Polarizing: 0 },
|
||||||
};
|
}
|
||||||
const result = decideMovie({ movies, people, ratings });
|
const result = decideMovie({ movies, people, ratings })
|
||||||
expect(result.winner.movie).toBe("Consensus");
|
expect(result.winner.movie).toBe("Consensus")
|
||||||
});
|
})
|
||||||
|
|
||||||
test("tie-breaker uses alphabetical order", () => {
|
test("tie-breaker uses alphabetical order", () => {
|
||||||
const movies = ["Alpha", "Beta"];
|
const movies = ["Alpha", "Beta"]
|
||||||
const people = ["A", "B"];
|
const people = ["A", "B"]
|
||||||
const ratings = {
|
const ratings = {
|
||||||
A: { Alpha: 2, Beta: 2 },
|
A: { Alpha: 2, Beta: 2 },
|
||||||
B: { Alpha: 2, Beta: 2 },
|
B: { Alpha: 2, Beta: 2 },
|
||||||
};
|
}
|
||||||
const result = decideMovie({ movies, people, ratings });
|
const result = decideMovie({ movies, people, ratings })
|
||||||
expect(result.winner.movie).toBe("Alpha");
|
expect(result.winner.movie).toBe("Alpha")
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest"
|
||||||
import {
|
import {
|
||||||
allUsersDone,
|
allUsersDone,
|
||||||
collectCompleteRatings,
|
collectCompleteRatings,
|
||||||
hasCompleteRatings,
|
hasCompleteRatings,
|
||||||
} from "../../src/shared/round-state.ts";
|
} from "../../src/shared/round-state.ts"
|
||||||
|
|
||||||
test("allUsersDone returns true when all done", () => {
|
test("allUsersDone returns true when all done", () => {
|
||||||
expect(allUsersDone(["A", "B"], ["A", "B"])).toBe(true);
|
expect(allUsersDone(["A", "B"], ["A", "B"])).toBe(true)
|
||||||
expect(allUsersDone(["A", "B"], ["A"])).toBe(false);
|
expect(allUsersDone(["A", "B"], ["A"])).toBe(false)
|
||||||
expect(allUsersDone([], [])).toBe(false);
|
expect(allUsersDone([], [])).toBe(false)
|
||||||
});
|
})
|
||||||
|
|
||||||
test("hasCompleteRatings detects missing ratings", () => {
|
test("hasCompleteRatings detects missing ratings", () => {
|
||||||
expect(hasCompleteRatings(["M1", "M2"], { M1: 2, M2: 5 })).toBe(true);
|
expect(hasCompleteRatings(["M1", "M2"], { M1: 2, M2: 5 })).toBe(true)
|
||||||
expect(hasCompleteRatings(["M1", "M2"], { M1: 2 })).toBe(false);
|
expect(hasCompleteRatings(["M1", "M2"], { M1: 2 })).toBe(false)
|
||||||
});
|
})
|
||||||
|
|
||||||
test("collectCompleteRatings filters incomplete voters", () => {
|
test("collectCompleteRatings filters incomplete voters", () => {
|
||||||
const result = collectCompleteRatings(["A", "B"], ["M1", "M2"], {
|
const result = collectCompleteRatings(["A", "B"], ["M1", "M2"], {
|
||||||
A: { M1: 1, M2: 2 },
|
A: { M1: 1, M2: 2 },
|
||||||
B: { M1: 3 },
|
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