diff --git a/.gitignore b/.gitignore index 2b5412b..9bb280f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ count.txt .vinxi todos.json .pnpm-store +.claude/settings.local.json +.agents/ +.claude/skills/ + diff --git a/CLAUDE.md b/CLAUDE.md index ee9e0b5..5a0582c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,35 +1,41 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. ## Project Overview -Mad CSS is a TanStack Start application for "The Ultimate CSS Tournament" - an event website featuring 16 developers battling for CSS glory. Built with React 19, TanStack Router, and deploys to Cloudflare Workers. +Mad CSS is a TanStack Start application for "The Ultimate CSS Tournament" - an +event website featuring 16 developers battling for CSS glory. Built with React +19, TanStack Router, and deploys to Cloudflare Workers. ## Commands +**Package manager: pnpm** + ```bash # Development -npm run dev # Start dev server on port 3000 +pnpm dev # Start dev server on port 3000 +[Note] I will run the dev command myself unless otherwise specified # Build & Deploy -npm run build # Build for production -npm run deploy # Build and deploy to Cloudflare Workers +pnpm build # Build for production +pnpm deploy # Build and deploy to Cloudflare Workers # Code Quality -npm run check # Run Biome linter and formatter checks -npm run lint # Lint only -npm run format # Format only +pnpm check # Run Biome linter and formatter checks +pnpm lint # Lint only +pnpm format # Format only # Testing -npm run test # Run Vitest tests +pnpm test # Run Vitest tests # Database -npm run db:generate # Generate Drizzle migrations from schema -npm run db:migrate:local # Apply migrations to local D1 -npm run db:migrate:prod # Apply migrations to production D1 -npm run db:studio # Open Drizzle Studio -npm run db:setup # Generate + migrate local (full setup) +pnpm db:generate # Generate Drizzle migrations from schema +pnpm db:migrate:local # Apply migrations to local D1 +pnpm db:migrate:prod # Apply migrations to production D1 +pnpm db:studio # Open Drizzle Studio +pnpm db:setup # Generate + migrate local (full setup) ``` ## Database Setup @@ -66,6 +72,20 @@ BETTER_AUTH_SECRET=your_random_secret BETTER_AUTH_URL=http://localhost:3000 ``` +### GitHub OAuth Setup + +1. Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App +2. Fill in: + - **Application name:** Mad CSS (Local) or similar + - **Homepage URL:** `http://localhost:3000/test` + - **Authorization callback URL:** + `http://localhost:3000/api/auth/callback/github` +3. Click "Register application" +4. Copy the **Client ID** to `GITHUB_CLIENT_ID` in `.dev.vars` +5. Generate a new **Client Secret** and copy to `GITHUB_CLIENT_SECRET` + +The login flow redirects to `/test` after authentication. + ### Production Deployment 1. Set secrets in Cloudflare dashboard (Workers > Settings > Variables): @@ -82,21 +102,162 @@ BETTER_AUTH_URL=http://localhost:3000 **Stack:** TanStack Start (SSR framework) + React 19 + Vite + Cloudflare Workers -**File-based routing:** Routes live in `src/routes/`. TanStack Router auto-generates `src/routeTree.gen.ts` - don't edit this file manually. +**File-based routing:** Routes live in `src/routes/`. TanStack Router +auto-generates `src/routeTree.gen.ts` - don't edit this file manually. **Key directories:** - `src/routes/` - Page components and API routes - `src/routes/__root.tsx` - Root layout, includes Header and devtools -- `src/components/` - Reusable components (Header, Ticket, Roster) +- `src/components/` - Reusable components (Header, Ticket, LoginSection, + bracket/, roster/, footer/, rules/) +- `src/lib/` - Auth setup (better-auth) and utilities (cfImage.ts for Cloudflare + Images) +- `src/data/` - Player data (players.ts with 16 contestants) - `src/styles/` - CSS files imported directly into components -- `public/` - Static assets (logos, images) +- `public/` - Static assets (logos, images, card artwork) **Path alias:** `@/*` maps to `./src/*` -**Styling:** Plain CSS with CSS custom properties defined in `src/styles/styles.css`. Uses custom fonts (Kaltjer, CollegiateBlackFLF, Inter) and texture backgrounds. +**Styling:** Plain CSS with CSS custom properties defined in +`src/styles/styles.css`. Uses custom fonts (Kaltjer, CollegiateBlackFLF, Inter) +and texture backgrounds. + +## Design System & Aesthetic + +The app has a **retro sports tournament / arcade** aesthetic inspired by vintage +ticket stubs, torn paper textures, and classic sports programs. + +### Color Palette + +```css +--orange: #f3370e; /* Primary accent, CTAs, highlights */ +--yellow: #ffae00; /* Secondary accent, warnings, badges */ +--black: #000000; /* Borders, shadows, text */ +--white: #ffffff; /* Text on dark backgrounds */ +--beige: #f5eeda; /* Paper/background color */ +``` + +### Typography + +- **Block/Display:** `Alfa Slab One` (`--font-block`) - Headlines, buttons, + badges. Always uppercase with letter-spacing (0.05-0.1em) +- **Serif:** Custom serif font (`--font-serif`) - Body text, descriptions +- **Sans:** `Inter` (`--font-sans`) - UI elements, small text + +### Key Design Patterns + +**Borders & Shadows:** +- 3-4px solid black borders on interactive elements +- 4px black box-shadows that shift on hover/active states +- Button hover: `transform: translate(2px, 2px)` + reduced shadow +- Button active: `transform: translate(4px, 4px)` + no shadow + +**Torn Paper Edges:** +- Use CSS `mask-image` with paper texture PNGs +- Top edge: `repeating-paper-top.png` +- Bottom edge: `repeating-paper-bottom.png` +- Combined with `mask-composite: exclude` + +**Ticket Stub Elements:** +- Dashed tear lines (4px dashed borders) +- Notched edges using radial gradients +- Barcode decorations +- "ADMIT ONE" style typography + +**Buttons:** +```css +.button { + background: var(--yellow); + border: 3px solid var(--black); + box-shadow: 4px 4px 0 var(--black); + font-family: var(--font-block); + text-transform: uppercase; + letter-spacing: 0.05em; + transition: transform 0.1s, box-shadow 0.1s; +} +.button:hover { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--black); +} +.button:active { + transform: translate(4px, 4px); + box-shadow: none; +} +``` + +**Cards/Containers:** +- Beige (`--beige`) or yellow (`--yellow`) backgrounds +- Thick black borders (4-6px) +- Optional torn paper mask on edges + +**Status Badges:** +- Uppercase, small font (0.625-0.75rem) +- Solid fill for active states (yellow bg, black text) +- Outline style for inactive states (transparent bg, colored border) + +### What to Avoid + +- Rounded corners (keep things sharp/angular) +- Gradients (except for mask effects) +- Drop shadows (use solid offset shadows only) +- Generic sans-serif styling +- Soft/pastel colors ## Code Style - Biome for linting/formatting (tabs, double quotes) - TypeScript strict mode +- XY Flow library for tournament bracket visualization + +## Bracket System + +**Tournament structure (FEEDER_GAMES in Bracket.tsx):** +- 16 players, single elimination bracket +- Left side games: r1-0, r1-1, r1-2, r1-3 → qf-0, qf-1 → sf-0 +- Right side games: r1-4, r1-5, r1-6, r1-7 → qf-2, qf-3 → sf-1 +- Finals: sf-0 winner vs sf-1 winner + +**Tournament stages (sequential order):** +1. Left R1 - games r1-0, r1-1, r1-2, r1-3 +2. Right R1 - games r1-4, r1-5, r1-6, r1-7 +3. QF - games qf-0, qf-1, qf-2, qf-3 (all together) +4. SF - games sf-0, sf-1 (semifinals) +5. Finals + +**Node sizing logic (`isNodeLarge()` in Bracket.tsx):** +- A node is "large" (`round1` class, ~130px with bio) when: + - Its feeder game IS decided (we know the player) + - The current game is NOT decided (active round) +- A node is "small" (`later` class, ~90px, no bio) when: + - Its feeder is NOT decided (TBD state), OR + - The current game IS already decided (completed) + +**Dynamic Y positioning:** +- Node Y offsets adjust based on `isNodeLarge()` to center nodes properly +- QF: 0.5 (large) vs 0.62 (small) +- SF: 1.35 (large) vs 1.5 (small) +- Finals: 3.35 (large) vs 3.5 (small) + +**Key constants (Bracket.tsx):** +- `NODE_HEIGHT = 70`, `VERTICAL_GAP = 76`, `MATCH_GAP = 146` +- `ROUND_GAP = 220` (horizontal spacing between rounds) + +**User picking flow:** +- Users can pick winners for games where both players are known +- Picks stored in `predictions` object keyed by game ID +- `isPickable` flag enables click handlers on player nodes + +## Comment Policy + +### Unacceptable Comments + +- Comments that repeat what code does +- Commented-out code (delete it) +- Obvious comments ("increment counter") +- Comments instead of good naming + +### Principle + +Code should be self-documenting. If you need a comment to explain WHAT the code +does, consider refactoring to make it clearer. diff --git a/README.md b/README.md index 77ee136..92b9fa8 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # MAD CSS + +## Development + +Just `pnpm install` then `pnpm dev` + +## Database + +Schema changes go through Drizzle. Generate a migration after editing the schema, then apply it. + +```bash +# Generate migration from schema changes +pnpm db:generate + +# Apply migrations locally (uses wrangler D1 --local) +pnpm db:migrate:local + +# Apply migrations to production +pnpm db:migrate:prod + +# Apply migrations to staging +pnpm db:migrate:staging + +# Both generate + migrate local in one shot +pnpm db:setup + +# Browse the database +pnpm db:studio +``` diff --git a/biome.json b/biome.json index cdfd60b..ab70548 100644 --- a/biome.json +++ b/biome.json @@ -24,7 +24,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "complexity": { + "noImportantStyles": "off" + } } }, "javascript": { diff --git a/drizzle/0000_daffy_shard.sql b/drizzle/0000_daffy_shard.sql new file mode 100644 index 0000000..19b6f08 --- /dev/null +++ b/drizzle/0000_daffy_shard.sql @@ -0,0 +1,94 @@ +CREATE TABLE `account` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint +CREATE TABLE `session` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint +CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint +CREATE TABLE `user` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, + `image` text, + `username` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint +CREATE TABLE `user_bracket_status` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `is_locked` integer DEFAULT false NOT NULL, + `locked_at` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_bracket_status_user_id_unique` ON `user_bracket_status` (`user_id`);--> statement-breakpoint +CREATE INDEX `user_bracket_status_userId_idx` ON `user_bracket_status` (`user_id`);--> statement-breakpoint +CREATE TABLE `user_prediction` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `game_id` text NOT NULL, + `predicted_winner_id` text NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `user_prediction_userId_idx` ON `user_prediction` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `user_prediction_userId_gameId_unique` ON `user_prediction` (`user_id`,`game_id`);--> statement-breakpoint +CREATE TABLE `user_score` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `round1_score` integer DEFAULT 0 NOT NULL, + `round2_score` integer DEFAULT 0 NOT NULL, + `round3_score` integer DEFAULT 0 NOT NULL, + `round4_score` integer DEFAULT 0 NOT NULL, + `total_score` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_score_user_id_unique` ON `user_score` (`user_id`);--> statement-breakpoint +CREATE INDEX `user_score_userId_idx` ON `user_score` (`user_id`);--> statement-breakpoint +CREATE INDEX `user_score_totalScore_idx` ON `user_score` (`total_score`);--> statement-breakpoint +CREATE TABLE `verification` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`); \ No newline at end of file diff --git a/drizzle/0000_rare_juggernaut.sql b/drizzle/0000_rare_juggernaut.sql deleted file mode 100644 index dc3063d..0000000 --- a/drizzle/0000_rare_juggernaut.sql +++ /dev/null @@ -1,53 +0,0 @@ -CREATE TABLE `account` ( - `id` text PRIMARY KEY NOT NULL, - `account_id` text NOT NULL, - `provider_id` text NOT NULL, - `user_id` text NOT NULL, - `access_token` text, - `refresh_token` text, - `id_token` text, - `access_token_expires_at` integer, - `refresh_token_expires_at` integer, - `scope` text, - `password` text, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint -CREATE TABLE `session` ( - `id` text PRIMARY KEY NOT NULL, - `expires_at` integer NOT NULL, - `token` text NOT NULL, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer NOT NULL, - `ip_address` text, - `user_agent` text, - `user_id` text NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint -CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint -CREATE TABLE `user` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `email` text NOT NULL, - `email_verified` integer DEFAULT false NOT NULL, - `image` text, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint -CREATE TABLE `verification` ( - `id` text PRIMARY KEY NOT NULL, - `identifier` text NOT NULL, - `value` text NOT NULL, - `expires_at` integer NOT NULL, - `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, - `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL -); ---> statement-breakpoint -CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 55bd2c8..dc4f2a9 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "8e24b20b-75e1-4780-815e-9b34da83ea0e", + "id": "91a916f3-894b-49ab-8482-b845d0f5c835", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -263,6 +263,13 @@ "notNull": false, "autoincrement": false }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "created_at": { "name": "created_at", "type": "integer", @@ -287,6 +294,13 @@ "email" ], "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true } }, "foreignKeys": {}, @@ -294,6 +308,282 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "user_bracket_status": { + "name": "user_bracket_status", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_locked": { + "name": "is_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_bracket_status_user_id_unique": { + "name": "user_bracket_status_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "user_bracket_status_userId_idx": { + "name": "user_bracket_status_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_bracket_status_user_id_user_id_fk": { + "name": "user_bracket_status_user_id_user_id_fk", + "tableFrom": "user_bracket_status", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_prediction": { + "name": "user_prediction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "predicted_winner_id": { + "name": "predicted_winner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_prediction_userId_idx": { + "name": "user_prediction_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "user_prediction_userId_gameId_unique": { + "name": "user_prediction_userId_gameId_unique", + "columns": [ + "user_id", + "game_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_prediction_user_id_user_id_fk": { + "name": "user_prediction_user_id_user_id_fk", + "tableFrom": "user_prediction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_score": { + "name": "user_score", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "round1_score": { + "name": "round1_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "round2_score": { + "name": "round2_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "round3_score": { + "name": "round3_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "round4_score": { + "name": "round4_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_score": { + "name": "total_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_score_user_id_unique": { + "name": "user_score_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "user_score_userId_idx": { + "name": "user_score_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "user_score_totalScore_idx": { + "name": "user_score_totalScore_idx", + "columns": [ + "total_score" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_score_user_id_user_id_fk": { + "name": "user_score_user_id_user_id_fk", + "tableFrom": "user_score", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "verification": { "name": "verification", "columns": { diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d434796..2c43aaf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1767919267096, - "tag": "0000_rare_juggernaut", + "when": 1769646596047, + "tag": "0000_daffy_shard", "breakpoints": true } ] diff --git a/package.json b/package.json index 259f66a..4a44a8a 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,12 @@ "@cloudflare/vite-plugin": "^1.20.1", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "^0.9.2", - "@tanstack/react-router": "^1.146.0", + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-router": "^1.157.16", "@tanstack/react-router-devtools": "^1.146.0", "@tanstack/react-router-ssr-query": "^1.146.0", - "@tanstack/react-start": "^1.146.0", - "@tanstack/router-plugin": "^1.146.0", + "@tanstack/react-start": "^1.157.16", + "@tanstack/router-plugin": "^1.157.16", "@xyflow/react": "^12.10.0", "better-auth": "^1.4.10", "drizzle-orm": "^0.45.1", @@ -35,7 +36,9 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", - "vite-tsconfig-paths": "^6.0.3" + "vite-tsconfig-paths": "^6.0.3", + "workers-og": "^0.0.27", + "zod": "^4.3.6" }, "devDependencies": { "@biomejs/biome": "2.3.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d9bd84..37c150c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,27 +17,30 @@ importers: '@tanstack/react-devtools': specifier: ^0.9.2 version: 0.9.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) + '@tanstack/react-query': + specifier: ^5.90.20 + version: 5.90.20(react@19.2.3) '@tanstack/react-router': - specifier: ^1.146.0 - version: 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.157.16 + version: 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-devtools': specifier: ^1.146.0 - version: 1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.146.2)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) + version: 1.146.2(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-router-ssr-query': specifier: ^1.146.0 - version: 1.146.2(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@19.2.3))(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.146.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.146.2(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.146.0 - version: 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + specifier: ^1.157.16 + version: 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@tanstack/router-plugin': - specifier: ^1.146.0 - version: 1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + specifier: ^1.157.16 + version: 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@xyflow/react': specifier: ^12.10.0 version: 12.10.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) better-auth: specifier: ^1.4.10 - version: 1.4.10(@tanstack/react-start@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.10(@tanstack/react-start@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)) drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9) @@ -56,6 +59,12 @@ importers: vite-tsconfig-paths: specifier: ^6.0.3 version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + workers-og: + specifier: ^0.0.27 + version: 0.0.27 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@biomejs/biome': specifier: 2.3.11 @@ -127,22 +136,42 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.28.5': resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.6': + resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.27.2': resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} @@ -151,12 +180,22 @@ packages: resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.3': resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-plugin-utils@7.27.1': resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} @@ -177,11 +216,20 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.28.5': resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -214,14 +262,26 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + '@better-auth/core@1.4.10': resolution: {integrity: sha512-AThrfb6CpG80wqkanfrbN2/fGOYzhGladHFf3JhaWt/3/Vtf4h084T6PJLrDE7M/vCCGYvDI1DkvP3P1OB2HAg==} peerDependencies: @@ -1164,6 +1224,10 @@ packages: '@remix-run/node-fetch-server@0.8.1': resolution: {integrity: sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig==} + '@resvg/resvg-wasm@2.4.0': + resolution: {integrity: sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==} + engines: {node: '>= 10'} + '@rolldown/pluginutils@1.0.0-beta.40': resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} @@ -1295,6 +1359,11 @@ packages: cpu: [x64] os: [win32] + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1455,12 +1524,12 @@ packages: peerDependencies: solid-js: '>=1.9.7' - '@tanstack/history@1.145.7': - resolution: {integrity: sha512-gMo/ReTUp0a3IOcZoI3hH6PLDC2R/5ELQ7P2yu9F6aEkA0wSQh+Q4qzMrtcKvF2ut0oE+16xWCGDo/TdYd6cEQ==} + '@tanstack/history@1.154.14': + resolution: {integrity: sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==} engines: {node: '>=12'} - '@tanstack/query-core@5.90.16': - resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} '@tanstack/react-devtools@0.9.2': resolution: {integrity: sha512-JNXvBO3jgq16GzTVm7p65n5zHNfMhnqF6Bm7CawjoqZrjEakxbM6Yvy63aKSIpbrdf+Wun2Xn8P0qD+vp56e1g==} @@ -1471,8 +1540,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query@5.90.16': - resolution: {integrity: sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==} + '@tanstack/react-query@5.90.20': + resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} peerDependencies: react: ^18 || ^19 @@ -1498,29 +1567,29 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-router@1.146.2': - resolution: {integrity: sha512-Oq/shGk5nCNyK/YhB9SGByeU3wgjNVzpGoDovuOvIacE9hsicZYOv9EnII1fEku8xavqWtN8D9wr21z2CDanjA==} + '@tanstack/react-router@1.157.16': + resolution: {integrity: sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg==} engines: {node: '>=12'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-client@1.146.2': - resolution: {integrity: sha512-zZ1PdU7MEEflqxBpdfSm51SlN/o9zLDAp4hf5zP5t93AxC9RO6I1SWFP5B65GdyVqG4ZE1OyUBOnIuom7E64sw==} + '@tanstack/react-start-client@1.157.16': + resolution: {integrity: sha512-r3XTxYPJXZ/szhbloxqT6CQtsoEjw8DjbnZh/3ZsQv2PLKTOl925cy7YVdQc2cWZyXtn5e19Ig78R+8tsoTpig==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-server@1.146.2': - resolution: {integrity: sha512-F/0ph0xcm60msreOC2aGTkDVXnHHaH3LiMuhHd/DRdmQlOdXC0NPKMw+R6I3fO52UUnKZYOfw6grJcg9YPdm6Q==} + '@tanstack/react-start-server@1.157.16': + resolution: {integrity: sha512-1YkBss4SUQ+HqVC1yGN/j7VNwjvdHHd3K58fASe0bz+uf7GrkGJlRXPkMJdxJkkmefYHQfyBL+q7o723N4CMYA==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start@1.146.2': - resolution: {integrity: sha512-UFEVeNvMMcuMCm/v1taq+APAgwn3Ivcw/f30N1V9thohC8jR5MxCcS+R3od2DPSTFbzrzTAI1P83uok3xQuluA==} + '@tanstack/react-start@1.157.16': + resolution: {integrity: sha512-FO6UYjsZyNaC0ickSSvClqfVZemp9/HWnbRJQU2dOKYQsI+wnznhLp9IkgG90iFBLcuMAWhcNHMiIuz603GJBg==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1533,8 +1602,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.146.2': - resolution: {integrity: sha512-MmTDiT6fpe+WBWYAuhp8oyzULBJX4oblm1kCqHDngf9mK3qcnNm5nkKk4d3Fk80QZmHS4DcRNFaFHKbLUVlZog==} + '@tanstack/router-core@1.157.16': + resolution: {integrity: sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA==} engines: {node: '>=12'} '@tanstack/router-devtools-core@1.146.2': @@ -1548,16 +1617,16 @@ packages: csstype: optional: true - '@tanstack/router-generator@1.146.2': - resolution: {integrity: sha512-0eO/iL50OrNLtG613iHLmps8AVJC7WChDz+njFViTiWCf20RMEjeUlKTffdrREx3v/QeaLVuxlBvLkXRqSW0yg==} + '@tanstack/router-generator@1.157.16': + resolution: {integrity: sha512-Ae2M00VTFjjED7glSCi/mMLENRzhEym6NgjoOx7UVNbCC/rLU/5ASDe5VIlDa8QLEqP5Pj088Gi51gjmRuICvQ==} engines: {node: '>=12'} - '@tanstack/router-plugin@1.146.2': - resolution: {integrity: sha512-4eHhoH2z69KfJTXqLqWAnfseGxzAiw5BX7wDatzXR5ODYXOu+JBIEMiZrP2YDclxPLVuetmBrGAluWSduH8O/g==} + '@tanstack/router-plugin@1.157.16': + resolution: {integrity: sha512-YQg7L06xyCJAYyrEJNZGAnDL8oChILU+G/eSDIwEfcWn5iLk+47x1Gcdxr82++47PWmOPhzuTo8edDQXWs7kAA==} engines: {node: '>=12'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.146.2 + '@tanstack/react-router': ^1.157.16 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1580,37 +1649,37 @@ packages: '@tanstack/query-core': '>=5.90.0' '@tanstack/router-core': '>=1.127.0' - '@tanstack/router-utils@1.143.11': - resolution: {integrity: sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA==} + '@tanstack/router-utils@1.154.7': + resolution: {integrity: sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA==} engines: {node: '>=12'} - '@tanstack/start-client-core@1.146.2': - resolution: {integrity: sha512-ZACRxwxs4BLVxwUoLOLeyuJwbrjHEnL3QEuxoOPVDsazGPJHiD/0fA6aZoCadh+YFP/U3OoKtjfh7SwxN/SQVA==} + '@tanstack/start-client-core@1.157.16': + resolution: {integrity: sha512-O+7H133MWQTkOxmXJNhrLXiOhDcBlxvpEcCd/N25Ga6eyZ7/P5vvFzNkSSxeQNkZV+RiPWnA5B75gT+U+buz3w==} engines: {node: '>=22.12.0'} - '@tanstack/start-fn-stubs@1.143.8': - resolution: {integrity: sha512-2IKUPh/TlxwzwHMiHNeFw95+L2sD4M03Es27SxMR0A60Qc4WclpaD6gpC8FsbuNASM2jBxk2UyeYClJxW1GOAQ==} + '@tanstack/start-fn-stubs@1.154.7': + resolution: {integrity: sha512-D69B78L6pcFN5X5PHaydv7CScQcKLzJeEYqs7jpuyyqGQHSUIZUjS955j+Sir8cHhuDIovCe2LmsYHeZfWf3dQ==} engines: {node: '>=22.12.0'} - '@tanstack/start-plugin-core@1.146.2': - resolution: {integrity: sha512-td6c2FxZdR1EQ2DjQ2j5ir/uxyqrQ2UAVEmqphJkHYqDGiPdXQ/LLHLDZciez4Yo6wJsIpNzmCXMEOG4r15YbA==} + '@tanstack/start-plugin-core@1.157.16': + resolution: {integrity: sha512-VmRXuvP5flryUAHeBM4Xb06n544qLtyA2cwmlQLRTUYtQiQEAdd9CvCGy8CPAly3f7eeXKqC7aX0v3MwWkLR8w==} engines: {node: '>=22.12.0'} peerDependencies: vite: '>=7.0.0' - '@tanstack/start-server-core@1.146.2': - resolution: {integrity: sha512-ugIEnZn84vR96a+G2ICfE7F5iiV5Tn45Y1omUe+tiXWIngb4tDvYTTFpNTIbD0NqoxfKyvN9YFbJH9OKtApDsQ==} + '@tanstack/start-server-core@1.157.16': + resolution: {integrity: sha512-PEltFleYfiqz6+KcmzNXxc1lXgT7VDNKP6G6i1TirdHBDbRJ9CIY+ASLPlhrRwqwA2PL9PpFjXZl8u5bH/+Q9A==} engines: {node: '>=22.12.0'} - '@tanstack/start-storage-context@1.146.2': - resolution: {integrity: sha512-kX9VzyJPqQR0hUVWbThLxDp8XHkGMmS/oFh9Yj5njXzHGxNm8y0nhEqKG7x+2o/Idxwqaf6mBOSp6FlONbrtEQ==} + '@tanstack/start-storage-context@1.157.16': + resolution: {integrity: sha512-56izE0oihAw2YRwYUEds2H+uO5dyT2CahXCgWX62+l+FHou09M9mSep68n1lBKPdphC2ZU3cPV7wnvgeraJWHg==} engines: {node: '>=22.12.0'} '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} - '@tanstack/virtual-file-routes@1.145.4': - resolution: {integrity: sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ==} + '@tanstack/virtual-file-routes@1.154.7': + resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==} engines: {node: '>=12'} '@testing-library/dom@10.4.1': @@ -1783,6 +1852,13 @@ packages: babel-dead-code-elimination@1.0.11: resolution: {integrity: sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ==} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1895,6 +1971,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001762: resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} @@ -1909,8 +1988,8 @@ packages: cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - cheerio@1.1.2: - resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} engines: {node: '>=20.18.1'} chokidar@3.6.0: @@ -1951,9 +2030,26 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + + css-gradient-parser@0.0.16: + resolution: {integrity: sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==} + engines: {node: '>=16'} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -2042,8 +2138,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - diff@8.0.2: - resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} dom-accessibility-api@0.5.16: @@ -2161,6 +2257,10 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -2179,6 +2279,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -2214,6 +2318,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -2246,6 +2353,9 @@ packages: picomatch: optional: true + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -2293,8 +2403,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - h3@2.0.1-rc.7: - resolution: {integrity: sha512-qbrRu1OLXmUYnysWOCVrYhtC/m8ZuXu/zCbo3U/KyphJxbPFiC76jHYwVrmEcss9uNAHO5BoUguQ46yEpgI2PA==} + h3@2.0.1-rc.11: + resolution: {integrity: sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw==} engines: {node: '>=20.11.1'} peerDependencies: crossws: ^0.4.1 @@ -2302,12 +2412,16 @@ packages: crossws: optional: true + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - htmlparser2@10.0.0: - resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} @@ -2389,6 +2503,9 @@ packages: engines: {node: '>=6'} hasBin: true + just-camel-case@6.2.0: + resolution: {integrity: sha512-ICenRLXwkQYLk3UyvLQZ+uKuwFVJ3JHFYFn7F2782G2Mv2hW8WPePqgdhpnjGaqkYtSVWnyCESZhGXUmY3/bEg==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -2470,6 +2587,9 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} @@ -2547,6 +2667,12 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -2576,6 +2702,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2585,8 +2714,8 @@ packages: engines: {node: '>=10'} hasBin: true - prettier@3.7.4: - resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -2654,6 +2783,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + satori@0.15.2: + resolution: {integrity: sha512-vu/49vdc8MzV5jUchs3TIRDCOkOvMc1iJ11MrZvhg9tE4ziKIEIBjBZvies6a9sfM2vQ2gc3dXeu6rCK7AztHA==} + engines: {node: '>=16'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -2676,8 +2809,8 @@ packages: peerDependencies: seroval: ^1.0 - seroval-plugins@1.4.2: - resolution: {integrity: sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA==} + seroval-plugins@1.5.0: + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 @@ -2686,8 +2819,8 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - seroval@1.4.2: - resolution: {integrity: sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==} + seroval@1.5.0: + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} set-cookie-parser@2.7.2: @@ -2731,8 +2864,8 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - srvx@0.10.0: - resolution: {integrity: sha512-NqIsR+wQCfkvvwczBh8J8uM4wTZx41K2lLSEp/3oMp917ODVVMtW5Me4epCmQ3gH8D+0b+/t4xxkUKutyhimTA==} + srvx@0.10.1: + resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==} engines: {node: '>=20.16.0'} hasBin: true @@ -2746,6 +2879,9 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2774,6 +2910,9 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2840,8 +2979,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.2: - resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2850,13 +2989,16 @@ packages: resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} engines: {node: '>=20.18.1'} - undici@7.18.2: - resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} + undici@7.19.2: + resolution: {integrity: sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -3002,6 +3144,9 @@ packages: engines: {node: '>=16'} hasBin: true + workers-og@0.0.27: + resolution: {integrity: sha512-QvwptQ0twmouQHiITUi3kYxEPCLdueC/U4msQ2xMz2iktd+iseSs7zlREw3T1dAsPxPw73FQlw8cXFsfANZPlw==} + wrangler@4.58.0: resolution: {integrity: sha512-Jm6EYtlt8iUcznOCPSMYC54DYkwrMNESzbH0Vh3GFHv/7XVw5gBC13YJAB+nWMRGJ+6B2dMzy/NVQS4ONL51Pw==} engines: {node: '>=20.0.0'} @@ -3053,6 +3198,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yoga-wasm-web@0.3.3: + resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -3062,8 +3210,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.5: - resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} @@ -3108,8 +3256,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.5': {} + '@babel/compat-data@7.28.6': {} + '@babel/core@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -3130,6 +3286,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -3138,6 +3314,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.28.6': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.28.5 @@ -3146,6 +3330,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-globals@7.28.0': {} '@babel/helper-module-imports@7.27.1': @@ -3155,6 +3347,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -3164,6 +3363,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/helper-plugin-utils@7.27.1': {} '@babel/helper-string-parser@7.27.1': {} @@ -3177,10 +3385,19 @@ snapshots: '@babel/template': 7.27.2 '@babel/types': 7.28.5 + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + '@babel/parser@7.28.5': dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -3209,6 +3426,12 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -3221,25 +3444,42 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.1.0 - better-call: 1.1.7(zod@4.3.5) + better-call: 1.1.7(zod@4.3.6) jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.3.5 + zod: 4.3.6 - '@better-auth/telemetry@1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + '@better-auth/telemetry@1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -3804,6 +4044,8 @@ snapshots: '@remix-run/node-fetch-server@0.8.1': {} + '@resvg/resvg-wasm@2.4.0': {} + '@rolldown/pluginutils@1.0.0-beta.40': {} '@rolldown/pluginutils@1.0.0-beta.53': {} @@ -3883,6 +4125,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + '@sindresorhus/is@7.2.0': {} '@solid-primitives/event-listener@2.4.3(solid-js@1.9.10)': @@ -4046,9 +4293,9 @@ snapshots: - csstype - utf-8-validate - '@tanstack/history@1.145.7': {} + '@tanstack/history@1.154.14': {} - '@tanstack/query-core@5.90.16': {} + '@tanstack/query-core@5.90.20': {} '@tanstack/react-devtools@0.9.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)': dependencies: @@ -4063,76 +4310,76 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-query@5.90.16(react@19.2.3)': + '@tanstack/react-query@5.90.20(react@19.2.3)': dependencies: - '@tanstack/query-core': 5.90.16 + '@tanstack/query-core': 5.90.20 react: 19.2.3 - '@tanstack/react-router-devtools@1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.146.2)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)': + '@tanstack/react-router-devtools@1.146.2(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)': dependencies: - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-devtools-core': 1.146.2(@tanstack/router-core@1.146.2)(csstype@3.2.3)(solid-js@1.9.10) + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-devtools-core': 1.146.2(@tanstack/router-core@1.157.16)(csstype@3.2.3)(solid-js@1.9.10) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@tanstack/router-core': 1.146.2 + '@tanstack/router-core': 1.157.16 transitivePeerDependencies: - csstype - solid-js - '@tanstack/react-router-ssr-query@1.146.2(@tanstack/query-core@5.90.16)(@tanstack/react-query@5.90.16(react@19.2.3))(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.146.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router-ssr-query@1.146.2(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.157.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/query-core': 5.90.16 - '@tanstack/react-query': 5.90.16(react@19.2.3) - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-ssr-query-core': 1.146.2(@tanstack/query-core@5.90.16)(@tanstack/router-core@1.146.2) + '@tanstack/query-core': 5.90.20 + '@tanstack/react-query': 5.90.20(react@19.2.3) + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-ssr-query-core': 1.146.2(@tanstack/query-core@5.90.20)(@tanstack/router-core@1.157.16) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - '@tanstack/router-core' - '@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/history': 1.145.7 + '@tanstack/history': 1.154.14 '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.146.2 + '@tanstack/router-core': 1.157.16 isbot: 5.1.32 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-client@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-start-client@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.146.2 - '@tanstack/start-client-core': 1.146.2 + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-core': 1.157.16 + '@tanstack/start-client-core': 1.157.16 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-server@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-start-server@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/history': 1.145.7 - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.146.2 - '@tanstack/start-client-core': 1.146.2 - '@tanstack/start-server-core': 1.146.2 + '@tanstack/history': 1.154.14 + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-core': 1.157.16 + '@tanstack/start-client-core': 1.157.16 + '@tanstack/start-server-core': 1.157.16 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - crossws - '@tanstack/react-start@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/react-start@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-client': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-server': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-utils': 1.143.11 - '@tanstack/start-client-core': 1.146.2 - '@tanstack/start-plugin-core': 1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) - '@tanstack/start-server-core': 1.146.2 + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-client': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-server': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-utils': 1.154.7 + '@tanstack/start-client-core': 1.157.16 + '@tanstack/start-plugin-core': 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/start-server-core': 1.157.16 pathe: 2.0.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -4151,19 +4398,19 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) - '@tanstack/router-core@1.146.2': + '@tanstack/router-core@1.157.16': dependencies: - '@tanstack/history': 1.145.7 + '@tanstack/history': 1.154.14 '@tanstack/store': 0.8.0 cookie-es: 2.0.0 - seroval: 1.4.2 - seroval-plugins: 1.4.2(seroval@1.4.2) + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.146.2(@tanstack/router-core@1.146.2)(csstype@3.2.3)(solid-js@1.9.10)': + '@tanstack/router-devtools-core@1.146.2(@tanstack/router-core@1.157.16)(csstype@3.2.3)(solid-js@1.9.10)': dependencies: - '@tanstack/router-core': 1.146.2 + '@tanstack/router-core': 1.157.16 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.10 @@ -4171,12 +4418,12 @@ snapshots: optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.146.2': + '@tanstack/router-generator@1.157.16': dependencies: - '@tanstack/router-core': 1.146.2 - '@tanstack/router-utils': 1.143.11 - '@tanstack/virtual-file-routes': 1.145.4 - prettier: 3.7.4 + '@tanstack/router-core': 1.157.16 + '@tanstack/router-utils': 1.154.7 + '@tanstack/virtual-file-routes': 1.154.7 + prettier: 3.8.1 recast: 0.23.11 source-map: 0.7.6 tsx: 4.21.0 @@ -4184,7 +4431,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/router-plugin@1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -4192,67 +4439,67 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@tanstack/router-core': 1.146.2 - '@tanstack/router-generator': 1.146.2 - '@tanstack/router-utils': 1.143.11 - '@tanstack/virtual-file-routes': 1.145.4 + '@tanstack/router-core': 1.157.16 + '@tanstack/router-generator': 1.157.16 + '@tanstack/router-utils': 1.154.7 + '@tanstack/virtual-file-routes': 1.154.7 babel-dead-code-elimination: 1.0.11 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-router': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color - '@tanstack/router-ssr-query-core@1.146.2(@tanstack/query-core@5.90.16)(@tanstack/router-core@1.146.2)': + '@tanstack/router-ssr-query-core@1.146.2(@tanstack/query-core@5.90.20)(@tanstack/router-core@1.157.16)': dependencies: - '@tanstack/query-core': 5.90.16 - '@tanstack/router-core': 1.146.2 + '@tanstack/query-core': 5.90.20 + '@tanstack/router-core': 1.157.16 - '@tanstack/router-utils@1.143.11': + '@tanstack/router-utils@1.154.7': dependencies: - '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/core': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/parser': 7.28.6 ansis: 4.2.0 - diff: 8.0.2 + diff: 8.0.3 pathe: 2.0.3 tinyglobby: 0.2.15 transitivePeerDependencies: - supports-color - '@tanstack/start-client-core@1.146.2': + '@tanstack/start-client-core@1.157.16': dependencies: - '@tanstack/router-core': 1.146.2 - '@tanstack/start-fn-stubs': 1.143.8 - '@tanstack/start-storage-context': 1.146.2 - seroval: 1.4.2 + '@tanstack/router-core': 1.157.16 + '@tanstack/start-fn-stubs': 1.154.7 + '@tanstack/start-storage-context': 1.157.16 + seroval: 1.5.0 tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/start-fn-stubs@1.143.8': {} + '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@tanstack/start-plugin-core@1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.28.5 - '@babel/types': 7.28.5 + '@babel/core': 7.28.6 + '@babel/types': 7.28.6 '@rolldown/pluginutils': 1.0.0-beta.40 - '@tanstack/router-core': 1.146.2 - '@tanstack/router-generator': 1.146.2 - '@tanstack/router-plugin': 1.146.2(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) - '@tanstack/router-utils': 1.143.11 - '@tanstack/start-client-core': 1.146.2 - '@tanstack/start-server-core': 1.146.2 - babel-dead-code-elimination: 1.0.11 - cheerio: 1.1.2 + '@tanstack/router-core': 1.157.16 + '@tanstack/router-generator': 1.157.16 + '@tanstack/router-plugin': 1.157.16(@tanstack/react-router@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/router-utils': 1.154.7 + '@tanstack/start-client-core': 1.157.16 + '@tanstack/start-server-core': 1.157.16 + babel-dead-code-elimination: 1.0.12 + cheerio: 1.2.0 exsolve: 1.0.8 pathe: 2.0.3 - srvx: 0.10.0 + srvx: 0.10.1 tinyglobby: 0.2.15 - ufo: 1.6.2 + ufo: 1.6.3 vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) xmlbuilder2: 4.0.3 @@ -4265,25 +4512,25 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-server-core@1.146.2': + '@tanstack/start-server-core@1.157.16': dependencies: - '@tanstack/history': 1.145.7 - '@tanstack/router-core': 1.146.2 - '@tanstack/start-client-core': 1.146.2 - '@tanstack/start-storage-context': 1.146.2 - h3-v2: h3@2.0.1-rc.7 - seroval: 1.4.2 + '@tanstack/history': 1.154.14 + '@tanstack/router-core': 1.157.16 + '@tanstack/start-client-core': 1.157.16 + '@tanstack/start-storage-context': 1.157.16 + h3-v2: h3@2.0.1-rc.11 + seroval: 1.5.0 tiny-invariant: 1.3.3 transitivePeerDependencies: - crossws - '@tanstack/start-storage-context@1.146.2': + '@tanstack/start-storage-context@1.157.16': dependencies: - '@tanstack/router-core': 1.146.2 + '@tanstack/router-core': 1.157.16 '@tanstack/store@0.8.0': {} - '@tanstack/virtual-file-routes@1.145.4': {} + '@tanstack/virtual-file-routes@1.154.7': {} '@testing-library/dom@10.4.1': dependencies: @@ -4489,26 +4736,37 @@ snapshots: transitivePeerDependencies: - supports-color + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + base64-js@0.0.8: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.11: {} - better-auth@1.4.10(@tanstack/react-start@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.10(@tanstack/react-start@1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: - '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.6))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.1.7(zod@4.3.5) + better-call: 1.1.7(zod@4.3.6) defu: 6.1.4 jose: 6.1.3 kysely: 0.28.9 nanostores: 1.1.0 - zod: 4.3.5 + zod: 4.3.6 optionalDependencies: - '@tanstack/react-start': 1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tanstack/react-start': 1.157.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) better-sqlite3: 12.5.0 drizzle-kit: 0.31.8 drizzle-orm: 0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.28.9) @@ -4517,14 +4775,14 @@ snapshots: solid-js: 1.9.10 vitest: 4.0.16(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0) - better-call@1.1.7(zod@4.3.5): + better-call@1.1.7(zod@4.3.6): dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 2.7.2 optionalDependencies: - zod: 4.3.5 + zod: 4.3.6 better-sqlite3@12.5.0: dependencies: @@ -4570,6 +4828,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + camelize@1.0.1: {} + caniuse-lite@1.0.30001762: {} chai@6.2.2: {} @@ -4585,18 +4845,18 @@ snapshots: domhandler: 5.0.3 domutils: 3.2.2 - cheerio@1.1.2: + cheerio@1.2.0: dependencies: cheerio-select: 2.1.0 dom-serializer: 2.0.0 domhandler: 5.0.3 domutils: 3.2.2 encoding-sniffer: 0.2.1 - htmlparser2: 10.0.0 + htmlparser2: 10.1.0 parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.18.2 + undici: 7.19.2 whatwg-mimetype: 4.0.0 chokidar@3.6.0: @@ -4639,6 +4899,14 @@ snapshots: cookie@1.1.1: {} + css-background-parser@0.1.0: {} + + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + + css-gradient-parser@0.0.16: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -4647,6 +4915,12 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + css-tree@3.1.0: dependencies: mdn-data: 2.12.2 @@ -4722,7 +4996,7 @@ snapshots: detect-libc@2.1.2: {} - diff@8.0.2: {} + diff@8.0.3: {} dom-accessibility-api@0.5.16: {} @@ -4761,6 +5035,8 @@ snapshots: electron-to-chromium@1.5.267: {} + emoji-regex-xs@2.0.1: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -4779,6 +5055,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@1.7.0: {} @@ -4904,6 +5182,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + esprima@4.0.1: {} estree-walker@3.0.3: @@ -4922,6 +5202,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.7.4: {} + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -4957,10 +5239,12 @@ snapshots: graceful-fs@4.2.11: {} - h3@2.0.1-rc.7: + h3@2.0.1-rc.11: dependencies: rou3: 0.7.12 - srvx: 0.10.0 + srvx: 0.10.1 + + hex-rgb@4.3.0: {} html-encoding-sniffer@6.0.0: dependencies: @@ -4968,12 +5252,12 @@ snapshots: transitivePeerDependencies: - '@exodus/crypto' - htmlparser2@10.0.0: + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 domutils: 3.2.2 - entities: 6.0.1 + entities: 7.0.1 http-proxy-agent@7.0.2: dependencies: @@ -5059,6 +5343,8 @@ snapshots: json5@2.2.3: {} + just-camel-case@6.2.0: {} + kleur@4.1.5: {} kysely@0.28.9: {} @@ -5117,6 +5403,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lru-cache@11.2.4: {} lru-cache@5.1.1: @@ -5187,6 +5478,13 @@ snapshots: dependencies: wrappy: 1.0.2 + pako@0.2.9: {} + + parse-css-color@0.2.1: + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -5214,6 +5512,8 @@ snapshots: picomatch@4.0.3: {} + postcss-value-parser@4.2.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -5235,7 +5535,7 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 - prettier@3.7.4: {} + prettier@3.8.1: {} pretty-format@27.5.1: dependencies: @@ -5327,6 +5627,20 @@ snapshots: safer-buffer@2.1.2: {} + satori@0.15.2: + dependencies: + '@shuding/opentype.js': 1.4.0-beta.0 + css-background-parser: 0.1.0 + css-box-shadow: 1.0.0-3 + css-gradient-parser: 0.0.16 + css-to-react-native: 3.2.0 + emoji-regex-xs: 2.0.1 + escape-html: 1.0.3 + linebreak: 1.1.0 + parse-css-color: 0.2.1 + postcss-value-parser: 4.2.0 + yoga-wasm-web: 0.3.3 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -5341,13 +5655,13 @@ snapshots: dependencies: seroval: 1.3.2 - seroval-plugins@1.4.2(seroval@1.4.2): + seroval-plugins@1.5.0(seroval@1.5.0): dependencies: - seroval: 1.4.2 + seroval: 1.5.0 seroval@1.3.2: {} - seroval@1.4.2: {} + seroval@1.5.0: {} set-cookie-parser@2.7.2: {} @@ -5410,7 +5724,7 @@ snapshots: source-map@0.7.6: {} - srvx@0.10.0: {} + srvx@0.10.1: {} stackback@0.0.2: {} @@ -5418,6 +5732,8 @@ snapshots: stoppable@1.1.0: {} + string.prototype.codepointat@0.2.1: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -5447,6 +5763,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} @@ -5499,18 +5817,23 @@ snapshots: typescript@5.9.3: {} - ufo@1.6.2: {} + ufo@1.6.3: {} undici-types@7.16.0: {} undici@7.14.0: {} - undici@7.18.2: {} + undici@7.19.2: {} unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -5632,6 +5955,13 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260107.1 '@cloudflare/workerd-windows-64': 1.20260107.1 + workers-og@0.0.27: + dependencies: + '@resvg/resvg-wasm': 2.4.0 + just-camel-case: 6.2.0 + satori: 0.15.2 + yoga-wasm-web: 0.3.3 + wrangler@4.58.0: dependencies: '@cloudflare/kv-asset-handler': 0.4.1 @@ -5667,6 +5997,8 @@ snapshots: yallist@3.1.1: {} + yoga-wasm-web@0.3.3: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -5682,7 +6014,7 @@ snapshots: zod@3.25.76: {} - zod@4.3.5: {} + zod@4.3.6: {} zustand@4.5.7(@types/react@19.2.7)(react@19.2.3): dependencies: diff --git a/public/fonts/DSEG7Classic-Bold.woff2 b/public/fonts/DSEG7Classic-Bold.woff2 new file mode 100644 index 0000000..558eec4 Binary files /dev/null and b/public/fonts/DSEG7Classic-Bold.woff2 differ diff --git a/scripts/seed-leaderboard.sql b/scripts/seed-leaderboard.sql new file mode 100644 index 0000000..d5fd3e7 --- /dev/null +++ b/scripts/seed-leaderboard.sql @@ -0,0 +1,110 @@ +-- Seed tournament results (Round 1 only for testing) +INSERT OR REPLACE INTO tournament_result (id, game_id, winner_id, created_at, updated_at) VALUES + ('tr-r1-0', 'r1-0', 'wes-bos', 1737331200000, 1737331200000), + ('tr-r1-1', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000), + ('tr-r1-2', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000), + ('tr-r1-3', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('tr-r1-4', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000), + ('tr-r1-5', 'r1-5', 'bramus', 1737331200000, 1737331200000), + ('tr-r1-6', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000), + ('tr-r1-7', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000); + +-- Add some quarterfinal results +INSERT OR REPLACE INTO tournament_result (id, game_id, winner_id, created_at, updated_at) VALUES + ('tr-qf-0', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000), + ('tr-qf-1', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000); + +-- Create dummy test users (for demo purposes) +-- Note: In production, users come from GitHub OAuth +INSERT OR REPLACE INTO user (id, name, email, email_verified, image, created_at, updated_at) VALUES + ('test-user-1', 'CSS Wizard', 'wizard@test.com', 1, 'https://avatars.githubusercontent.com/u/1?v=4', 1737331200000, 1737331200000), + ('test-user-2', 'Flexbox Fan', 'flex@test.com', 1, 'https://avatars.githubusercontent.com/u/2?v=4', 1737331200000, 1737331200000), + ('test-user-3', 'Grid Master', 'grid@test.com', 1, 'https://avatars.githubusercontent.com/u/3?v=4', 1737331200000, 1737331200000), + ('test-user-4', 'Animation Ace', 'anim@test.com', 1, 'https://avatars.githubusercontent.com/u/4?v=4', 1737331200000, 1737331200000), + ('test-user-5', 'Selector Savant', 'select@test.com', 1, 'https://avatars.githubusercontent.com/u/5?v=4', 1737331200000, 1737331200000); + +-- Mark test users' brackets as locked +INSERT OR REPLACE INTO user_bracket_status (id, user_id, is_locked, locked_at, created_at) VALUES + ('bs-1', 'test-user-1', 1, 1737331200000, 1737331200000), + ('bs-2', 'test-user-2', 1, 1737331200000, 1737331200000), + ('bs-3', 'test-user-3', 1, 1737331200000, 1737331200000), + ('bs-4', 'test-user-4', 1, 1737331200000, 1737331200000), + ('bs-5', 'test-user-5', 1, 1737331200000, 1737331200000); + +-- User 1 predictions (perfect Round 1, 1/2 QF = 80 + 20 = 100) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p1-r1-0', 'test-user-1', 'r1-0', 'wes-bos', 1737331200000, 1737331200000), + ('p1-r1-1', 'test-user-1', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000), + ('p1-r1-2', 'test-user-1', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000), + ('p1-r1-3', 'test-user-1', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('p1-r1-4', 'test-user-1', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000), + ('p1-r1-5', 'test-user-1', 'r1-5', 'bramus', 1737331200000, 1737331200000), + ('p1-r1-6', 'test-user-1', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000), + ('p1-r1-7', 'test-user-1', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000), + ('p1-qf-0', 'test-user-1', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000), + ('p1-qf-1', 'test-user-1', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000); + +-- User 2 predictions (6/8 Round 1, 2/2 QF = 60 + 40 = 100) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p2-r1-0', 'test-user-2', 'r1-0', 'wes-bos', 1737331200000, 1737331200000), + ('p2-r1-1', 'test-user-2', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000), + ('p2-r1-2', 'test-user-2', 'r1-2', 'ania-kubow', 1737331200000, 1737331200000), + ('p2-r1-3', 'test-user-2', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('p2-r1-4', 'test-user-2', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000), + ('p2-r1-5', 'test-user-2', 'r1-5', 'bramus', 1737331200000, 1737331200000), + ('p2-r1-6', 'test-user-2', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000), + ('p2-r1-7', 'test-user-2', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000), + ('p2-qf-0', 'test-user-2', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000), + ('p2-qf-1', 'test-user-2', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000); + +-- User 3 predictions (5/8 Round 1, 1/2 QF = 50 + 20 = 70) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p3-r1-0', 'test-user-3', 'r1-0', 'scott-tolinski', 1737331200000, 1737331200000), + ('p3-r1-1', 'test-user-3', 'r1-1', 'kevin-powell', 1737331200000, 1737331200000), + ('p3-r1-2', 'test-user-3', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000), + ('p3-r1-3', 'test-user-3', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('p3-r1-4', 'test-user-3', 'r1-4', 'jen-simmons', 1737331200000, 1737331200000), + ('p3-r1-5', 'test-user-3', 'r1-5', 'rachel-andrew', 1737331200000, 1737331200000), + ('p3-r1-6', 'test-user-3', 'r1-6', 'cassie-evans', 1737331200000, 1737331200000), + ('p3-r1-7', 'test-user-3', 'r1-7', 'miriam-suzanne', 1737331200000, 1737331200000), + ('p3-qf-0', 'test-user-3', 'qf-0', 'wes-bos', 1737331200000, 1737331200000), + ('p3-qf-1', 'test-user-3', 'qf-1', 'josh-comeau', 1737331200000, 1737331200000); + +-- User 4 predictions (4/8 Round 1, 0/2 QF = 40 + 0 = 40) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p4-r1-0', 'test-user-4', 'r1-0', 'wes-bos', 1737331200000, 1737331200000), + ('p4-r1-1', 'test-user-4', 'r1-1', 'adam-argyle', 1737331200000, 1737331200000), + ('p4-r1-2', 'test-user-4', 'r1-2', 'adam-wathan', 1737331200000, 1737331200000), + ('p4-r1-3', 'test-user-4', 'r1-3', 'cassidy-williams', 1737331200000, 1737331200000), + ('p4-r1-4', 'test-user-4', 'r1-4', 'kyle-cook', 1737331200000, 1737331200000), + ('p4-r1-5', 'test-user-4', 'r1-5', 'rachel-andrew', 1737331200000, 1737331200000), + ('p4-r1-6', 'test-user-4', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000), + ('p4-r1-7', 'test-user-4', 'r1-7', 'css-ninja', 1737331200000, 1737331200000), + ('p4-qf-0', 'test-user-4', 'qf-0', 'wes-bos', 1737331200000, 1737331200000), + ('p4-qf-1', 'test-user-4', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000); + +-- User 5 predictions (3/8 Round 1, 1/2 QF = 30 + 20 = 50) +INSERT OR REPLACE INTO user_prediction (id, user_id, game_id, predicted_winner_id, created_at, updated_at) VALUES + ('p5-r1-0', 'test-user-5', 'r1-0', 'scott-tolinski', 1737331200000, 1737331200000), + ('p5-r1-1', 'test-user-5', 'r1-1', 'adam-argyle', 1737331200000, 1737331200000), + ('p5-r1-2', 'test-user-5', 'r1-2', 'ania-kubow', 1737331200000, 1737331200000), + ('p5-r1-3', 'test-user-5', 'r1-3', 'josh-comeau', 1737331200000, 1737331200000), + ('p5-r1-4', 'test-user-5', 'r1-4', 'jen-simmons', 1737331200000, 1737331200000), + ('p5-r1-5', 'test-user-5', 'r1-5', 'bramus', 1737331200000, 1737331200000), + ('p5-r1-6', 'test-user-5', 'r1-6', 'stephanie-eckles', 1737331200000, 1737331200000), + ('p5-r1-7', 'test-user-5', 'r1-7', 'css-ninja', 1737331200000, 1737331200000), + ('p5-qf-0', 'test-user-5', 'qf-0', 'kevin-powell', 1737331200000, 1737331200000), + ('p5-qf-1', 'test-user-5', 'qf-1', 'adam-wathan', 1737331200000, 1737331200000); + +-- Insert user scores (calculated based on above predictions) +-- User 1: R1=80, R2=20 (1 correct of 2 played), Total=100 +-- User 2: R1=60, R2=40 (2 correct), Total=100 +-- User 3: R1=50, R2=20 (1 correct), Total=70 +-- User 4: R1=40, R2=0, Total=40 +-- User 5: R1=30, R2=20 (1 correct), Total=50 +INSERT OR REPLACE INTO user_score (id, user_id, round1_score, round2_score, round3_score, round4_score, total_score, created_at, updated_at) VALUES + ('score-1', 'test-user-1', 80, 20, 0, 0, 100, 1737331200000, 1737331200000), + ('score-2', 'test-user-2', 60, 40, 0, 0, 100, 1737331200000, 1737331200000), + ('score-3', 'test-user-3', 50, 20, 0, 0, 70, 1737331200000, 1737331200000), + ('score-5', 'test-user-5', 30, 20, 0, 0, 50, 1737331200000, 1737331200000), + ('score-4', 'test-user-4', 40, 0, 0, 0, 40, 1737331200000, 1737331200000); diff --git a/src/components/AdminButton.tsx b/src/components/AdminButton.tsx new file mode 100644 index 0000000..87fb33d --- /dev/null +++ b/src/components/AdminButton.tsx @@ -0,0 +1,159 @@ +import { Link, useLocation } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { useSession } from "@/lib/auth-client"; +import { buildResultsUpToStage, type SimulationStage } from "@/lib/simulation"; +import "@/styles/admin-button.css"; + +const STAGES: { value: string; label: string }[] = [ + { value: "default", label: "Default (Live Data)" }, + { value: "r1-left", label: "R1 Left Complete" }, + { value: "r1-right", label: "R1 Right Complete" }, + { value: "quarterfinals", label: "Quarterfinals Complete" }, + { value: "semifinals", label: "Semifinals Complete" }, + { value: "finals", label: "Champion Crowned" }, +]; + +export function AdminButton() { + const location = useLocation(); + const { data: session } = useSession(); + const [isAdmin, setIsAdmin] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [simStage, setSimStage] = useState("default"); + const popoverRef = useRef(null); + const buttonRef = useRef(null); + + // Fetch the user's admin status for client-side check (UI only) + useEffect(() => { + if (!session?.user) { + setIsAdmin(false); + return; + } + + fetch("/api/admin/check") + .then((res) => { + if (res.ok) return res.json(); + return null; + }) + .then((data) => { + setIsAdmin(data?.isAdmin ?? false); + }) + .catch(() => { + setIsAdmin(false); + }); + }, [session?.user]); + + // Close popover when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + popoverRef.current && + buttonRef.current && + !popoverRef.current.contains(event.target as Node) && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + const handleStageChange = (stage: string) => { + setSimStage(stage); + if (stage === "default") { + window.dispatchEvent( + new CustomEvent("tournament-results-changed", { detail: {} }), + ); + } else { + const results = buildResultsUpToStage(stage as SimulationStage); + window.dispatchEvent( + new CustomEvent("tournament-results-changed", { detail: { results } }), + ); + } + }; + + // Don't show on admin page itself + if (location.pathname === "/admin") { + return null; + } + + // Only show for admin users (client-side check for UI only) + if (!isAdmin) { + return null; + } + + return ( +
+ {isOpen && ( +
+ + + Admin Dashboard + + +
+ +
+ + +
+
+ )} + + +
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 3cef0ab..b25fc03 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router"; +import { UserMenu } from "./UserMenu"; import "@/styles/header.css"; @@ -6,9 +7,9 @@ export function Header() { return (
- {/* TODO this should be a vector image */} Mad CSS Logo +
); } diff --git a/src/components/LoginSection.tsx b/src/components/LoginSection.tsx index 08b68c9..0f80781 100644 --- a/src/components/LoginSection.tsx +++ b/src/components/LoginSection.tsx @@ -1,55 +1,186 @@ +import { usePredictionsContext } from "@/context/PredictionsContext"; +import { getNextGameTime } from "@/data/players"; +import { useCountdown } from "@/hooks/useCountdown"; import { authClient } from "@/lib/auth-client"; +import { Scoreboard } from "./scoreboard/Scoreboard"; import "@/styles/login.css"; +const ROUND_LABELS: Record = { + "left-r1": "Left R1", + "right-r1": "Right R1", + qf: "Quarterfinals", + sf: "Semifinals", + final: "Finals", +}; + +// Sub-component: Share buttons (copy link, X, Bluesky) +export function LoginSectionShare({ + twitterShareUrl, + blueskyShareUrl, + copied, + onCopyLink, +}: { + twitterShareUrl: string | null; + blueskyShareUrl: string | null; + copied: boolean; + onCopyLink: () => Promise; +}) { + return ( +
+
+ + Share your bracket +
+
+ + {twitterShareUrl && ( + + + Share on X + + )} + {blueskyShareUrl && ( + + + Share on Bluesky + + )} +
+
+ ); +} + export function LoginSection() { + const ctx = usePredictionsContext(); + + const isLocked = ctx?.isLocked ?? false; + const error = ctx?.error ?? null; + const deadline = ctx?.deadline; + const isDeadlinePassed = ctx?.isDeadlinePassed ?? false; const { data: session, isPending } = authClient.useSession(); + const countdown = useCountdown(deadline); + const isUrgent = + countdown.totalMs > 0 && countdown.totalMs < 24 * 60 * 60 * 1000; + + // Next game countdown + const nextGame = getNextGameTime(); + const nextGameCountdown = useCountdown(nextGame?.time); + const nextGameLabel = nextGame ? ROUND_LABELS[nextGame.round] : null; if (isPending) { return (
- ... + Loading...
); } if (session?.user) { + const hasDeadlineMessage = isDeadlinePassed && !isLocked; + if (!hasDeadlineMessage && !error) return null; + return (
- {session.user.name} -
-

- You're in, {session.user.name}! -

-

Lock in your picks below.

-
- + {hasDeadlineMessage && ( +
Deadline has passed
+ )} + {error &&

{error}

}
); } + // Logged out state return (
-
-

Think you can call it?

-

- Lock in your predictions before Round 1 and compete - for mass internet clout. Perfect bracket = mass internet clout. -

-
+

Think you can call it?

+

+ Lock in your predictions before March 6 for a chance to + win some amazing prizes! +

+ {deadline && countdown.totalMs > 0 ? ( + + ) : ( + nextGame && + nextGameCountdown.totalMs > 0 && ( +
+ + {nextGameLabel} results in: + + +
+ ) + )}
+ ); +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..adc9826 --- /dev/null +++ b/src/components/UserMenu.tsx @@ -0,0 +1,73 @@ +import { useRef, useState, useEffect } from "react"; +import { authClient } from "@/lib/auth-client"; +import "@/styles/user-menu.css"; + +export function UserMenu() { + const { data: session, isPending } = authClient.useSession(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) { + document.addEventListener("click", handleClickOutside); + } + return () => document.removeEventListener("click", handleClickOutside); + }, [open]); + + if (isPending || !session?.user) return null; + + const { name, image } = session.user; + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/bracket/Bracket.tsx b/src/components/bracket/Bracket.tsx index b362862..83c59bc 100644 --- a/src/components/bracket/Bracket.tsx +++ b/src/components/bracket/Bracket.tsx @@ -8,24 +8,247 @@ import { ReactFlow, type ReactFlowInstance, } from "@xyflow/react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import "@xyflow/react/dist/style.css"; +import { usePredictionsContext } from "@/context/PredictionsContext"; import { + ALL_GAME_IDS, bracket, - type Game, - isLoser, - isWinner, - type Player, + getNextGameTime, splitForDisplay, + TOTAL_GAMES, } from "@/data/players"; +import { useCountdown } from "@/hooks/useCountdown"; +import { Scoreboard } from "@/components/scoreboard/Scoreboard"; +import { getPickablePlayersForGame } from "@/hooks/usePredictions"; +import { authClient } from "@/lib/auth-client"; +import { LoginSectionShare } from "@/components/LoginSection"; +import type { NodeContext } from "./bracketTypes"; +import { + generateChampionshipNode, + generateFinalistNode, + generateQuarterNodes, + generateRound1Nodes, + generateSemiNodes, +} from "./nodeGenerators"; import { EmptySlotFlow, PlayerNodeFlow } from "./PlayerNode"; import "./bracket.css"; -// Ring colors for each side -const LEFT_RING_COLOR = "#f3370e"; -const RIGHT_RING_COLOR = "#5CE1E6"; +export interface BracketProps { + isInteractive?: boolean; + predictions?: Record; + onPick?: (gameId: string, playerId: string) => void; + isLocked?: boolean; + isAuthenticated?: boolean; + getPickablePlayers?: (gameId: string) => string[]; + tournamentResults?: Record; + showPicks?: boolean; + onToggleShowPicks?: () => void; +} + +function BracketToggle({ + showPicks, + onToggle, +}: { + showPicks: boolean; + onToggle: () => void; +}) { + const { data: session } = authClient.useSession(); + const ctx = usePredictionsContext(); + const userImage = session?.user?.image; + const isLocked = ctx?.isLocked ?? false; + const pickCount = ctx?.pickCount ?? 0; + + return ( +
+ + +
+ ); +} + +const ROUND_LABELS: Record = { + "left-r1": "Left R1", + "right-r1": "Right R1", + qf: "Quarterfinals", + sf: "Semifinals", + final: "Finals", +}; + +function NextResultsCountdown() { + const ctx = usePredictionsContext(); + const { data: session } = authClient.useSession(); + const isLocked = ctx?.isLocked ?? false; + const nextGame = getNextGameTime(); + const nextGameCountdown = useCountdown(nextGame?.time); + const nextGameLabel = nextGame ? ROUND_LABELS[nextGame.round] : null; + + if (!session?.user || !isLocked || !nextGame || nextGameCountdown.totalMs <= 0) return null; + + return ( +
+ + {nextGameLabel} results in: + + +
+ ); +} + +function BracketToolbar() { + const ctx = usePredictionsContext(); + const { data: session } = authClient.useSession(); + const dialogRef = useRef(null); + const [copied, setCopied] = useState(false); + + const isLoggedIn = !!session?.user && !!ctx; + const pickCount = ctx?.pickCount ?? 0; + const isLocked = ctx?.isLocked ?? false; + const isSaving = ctx?.isSaving ?? false; + const hasChanges = ctx?.hasChanges ?? false; + const isDeadlinePassed = ctx?.isDeadlinePassed ?? false; + const canLock = pickCount === TOTAL_GAMES && !isLocked && !isDeadlinePassed; + const showActions = isLoggedIn && !isLocked && !isDeadlinePassed; + + const username = (session?.user as { username?: string })?.username; + const shareUrl = username + ? `${typeof window !== "undefined" ? window.location.origin : ""}/bracket/${username}` + : null; + const twitterShareUrl = username + ? `https://twitter.com/intent/tweet?text=${encodeURIComponent(`Check out my March Mad CSS bracket picks! 🏀\n\n${shareUrl}`)}` + : null; + const blueskyShareUrl = username + ? `https://bsky.app/intent/compose?text=${encodeURIComponent(`Check out my March Mad CSS bracket picks! 🏀\n\n${shareUrl}`)}` + : null; + const showShare = isLoggedIn && isLocked && !!shareUrl; + + const handleCopyLink = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + const input = document.createElement("input"); + input.value = shareUrl; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (!showActions && !showShare) return null; + + return ( +
+ {showActions && ( +
+ + + {pickCount > 0 && ( + + )} + +
+

Lock your bracket?

+

This cannot be undone.

+
+ + +
+
+
+
+ )} + {showShare && ( + + )} +
+ ); +} + +export type EdgeState = "winner" | "loser" | "pending" | "pickable" | "default"; + +export type EdgeHoverState = + | "none" + | "hovered-pick" + | "hovered-competitor" + | "hovered-incoming"; -// Custom edge component function BracketEdge({ sourceX, sourceY, @@ -34,22 +257,20 @@ function BracketEdge({ sourcePosition, targetPosition, target, + data, }: EdgeProps) { - // For finalist-to-champ edges, draw a custom path with lowered horizontal line - if (target === "championship") { - const horizontalY = sourceY + 30; // Lower the horizontal line to connect finalists - // Extend the line up to close the gap to the champion card - const edgePath = `M ${sourceX} ${sourceY} L ${sourceX} ${horizontalY} L ${targetX} ${horizontalY} L ${targetX} ${targetY - 35}`; - // No drop-shadow filter to avoid shadow overlap where lines meet - return ( - - ); + const edgeState = (data?.state as EdgeState) ?? "default"; + const hoverState = (data?.hoverState as EdgeHoverState) ?? "none"; + + const STROKE_WIDTH = 3; + + let state: string = edgeState; + if (hoverState === "hovered-pick") { + state = "hover-pick"; + } else if (hoverState === "hovered-competitor") { + state = "hover-competitor"; + } else if (hoverState === "hovered-incoming") { + state = "pickable"; } const [edgePath] = getSmoothStepPath({ @@ -61,579 +282,536 @@ function BracketEdge({ targetPosition, borderRadius: 0, }); - return ; + + return ( + <> + + + + ); } -// Register custom node types const nodeTypes = { playerNode: PlayerNodeFlow, emptySlot: EmptySlotFlow, }; -// Register custom edge types const edgeTypes = { bracket: BracketEdge, }; -// Node dimensions for positioning -const NODE_HEIGHT = 70; -const VERTICAL_GAP = 76; -const MATCH_GAP = NODE_HEIGHT + VERTICAL_GAP; -const ROUND_GAP = 220; - -// Get the appropriate photo path based on elimination status -function getPhotoPath(player: Player, isEliminated: boolean): string { - // Photos are stored as /avatars/color/name.png and /avatars/bw/name.png - // player.photo is like /avatars/name.png, so we need to insert the subfolder - const filename = player.photo.replace("/avatars/", ""); - return isEliminated - ? `/avatars/bw/${filename}` - : `/avatars/color/${filename}`; -} +function generateNodes( + isInteractive: boolean, + predictions: Record = {}, + onPick?: (gameId: string, playerId: string) => void, + isPickingEnabled = false, + tournamentResults: Record = {}, + showPicks = false, + isLocked = false, +): Node[] { + const hasResults = Object.keys(tournamentResults).length > 0; + + const pickablePlayersCache: Record< + string, + [string | undefined, string | undefined] + > = {}; + for (const gameId of ALL_GAME_IDS) { + pickablePlayersCache[gameId] = getPickablePlayersForGame( + gameId, + predictions, + ); + } -// Convert a Player to PlayerData for the node -function playerToNodeData( - player: Player, - game: Game, - ringColor: string, - side: "left" | "right", - round: "round1" | "later" = "later", -): { - photo: string; - name: string; - byline: string; - ringColor: string; - isWinner: boolean; - isEliminated: boolean; - side: "left" | "right"; - round: "round1" | "later"; -} { - const isEliminated = isLoser(game, player); - return { - photo: getPhotoPath(player, isEliminated), - name: player.name, - byline: player.byline, - ringColor, - isWinner: isWinner(game, player), - isEliminated, - side, - round, + const ctx: NodeContext = { + hasResults, + tournamentResults, + predictions, + pickablePlayersCache, + isInteractive, + isPickingEnabled, + showPicks, + isLocked, + onPick, }; -} -// Create a node for either a player or an empty slot -function createNode( - id: string, - player: Player | undefined, - game: Game, - ringColor: string, - position: { x: number; y: number }, - side: "left" | "right", - round: "round1" | "later" = "later", - emptyText?: string, -): Node { - if (player) { - return { - id, - type: "playerNode", - position, - data: playerToNodeData(player, game, ringColor, side, round), - }; - } - return { - id, - type: "emptySlot", - position, - data: { text: emptyText, side, ringColor, round }, - }; + return [ + ...generateRound1Nodes({ side: "left", ctx }), + ...generateRound1Nodes({ side: "right", ctx }), + ...generateQuarterNodes({ side: "left", ctx }), + ...generateQuarterNodes({ side: "right", ctx }), + ...generateSemiNodes({ side: "left", ctx }), + ...generateSemiNodes({ side: "right", ctx }), + generateFinalistNode({ side: "left", ctx }), + generateFinalistNode({ side: "right", ctx }), + generateChampionshipNode(ctx), + ]; } -// Generate nodes from bracket data -function generateNodes(): Node[] { - const nodes: Node[] = []; - - // Split each round into left/right halves - const round1 = splitForDisplay(bracket.round1); - const quarters = splitForDisplay(bracket.quarters); - const semis = splitForDisplay(bracket.semis); - - // =========================================================================== - // LEFT SIDE (first half of each round) - // =========================================================================== - - // Round 1 - Left side (games 0-3) - round1.left.forEach((game, gameIndex) => { - const baseY = gameIndex * 2 * MATCH_GAP; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - LEFT_RING_COLOR, - { - x: 0, - y: baseY, - }, - "left", - "round1", - ), - ); - - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - LEFT_RING_COLOR, - { - x: 0, - y: baseY + MATCH_GAP, - }, - "left", - "round1", - ), - ); - }); +const EDGE_SOURCE_MAP: Map = (() => { + const map = new Map(); + function add(source: string, target: string) { + const existing = map.get(target); + if (existing) { + existing.push(source); + } else { + map.set(target, [source]); + } + } + const r1 = splitForDisplay(bracket.round1); + const qf = splitForDisplay(bracket.quarters); + const sf = splitForDisplay(bracket.semis); + + for (const [side, r1Games, qfGames, sfGames] of [ + ["left", r1.left, qf.left, sf.left], + ["right", r1.right, qf.right, sf.right], + ] as const) { + r1Games.forEach((game, i) => { + const qfGame = qfGames[Math.floor(i / 2)]; + const target = `${qfGame.id}-p${(i % 2) + 1}`; + add(`${game.id}-p1`, target); + add(`${game.id}-p2`, target); + }); + qfGames.forEach((game, i) => { + const semiGame = sfGames[0]; + const target = `${semiGame.id}-p${i + 1}`; + add(`${game.id}-p1`, target); + add(`${game.id}-p2`, target); + }); + sfGames.forEach((game) => { + const target = `${side}-finalist`; + add(`${game.id}-p1`, target); + add(`${game.id}-p2`, target); + }); + add(`${side}-finalist`, "championship"); + } + return map; +})(); - // Quarterfinals - Left side (games 0-1) - quarters.left.forEach((game, gameIndex) => { - const baseY = gameIndex * 4 * MATCH_GAP + MATCH_GAP * 0.62; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - LEFT_RING_COLOR, - { - x: ROUND_GAP, - y: baseY, - }, - "left", - ), - ); +function parseSourceNodeId(sourceNodeId: string): { + gameId: string; + slot: "p1" | "p2"; +} { + const lastDash = sourceNodeId.lastIndexOf("-"); + const slot = sourceNodeId.slice(lastDash + 1) as "p1" | "p2"; + const gameId = sourceNodeId.slice(0, lastDash); + return { gameId, slot }; +} - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - LEFT_RING_COLOR, - { - x: ROUND_GAP, - y: baseY + 2 * MATCH_GAP, - }, - "left", - ), - ); - }); +function getSourcePlayerId( + sourceNodeId: string, + nodes: Node[], +): string | undefined { + const node = nodes.find((n) => n.id === sourceNodeId); + if (!node) return undefined; + return (node.data as { playerId?: string })?.playerId; +} - // Semifinals - Left side (game 0) - semis.left.forEach((game) => { - const baseY = 1.5 * MATCH_GAP; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - LEFT_RING_COLOR, - { - x: ROUND_GAP * 2, - y: baseY, - }, - "left", - ), - ); +function computeEdgeState( + sourceNodeId: string, + targetNodeId: string, + tournamentResults: Record, + predictions: Record, + showPicks: boolean, + nodes: Node[], + allEdges: Edge[], +): EdgeState { + const { gameId } = parseSourceNodeId(sourceNodeId); + const playerId = getSourcePlayerId(sourceNodeId, nodes); + const results = showPicks ? predictions : tournamentResults; + + // Finalist-to-championship edges: check if the final game is decided + if (sourceNodeId === "left-finalist" || sourceNodeId === "right-finalist") { + const finalWinner = results.final; + if (finalWinner && playerId) { + return finalWinner === playerId ? "winner" : "loser"; + } + const targetNode = nodes.find((n) => n.id === targetNodeId); + const targetIsEmpty = targetNode?.type === "emptySlot"; + if (targetIsEmpty) { + const feedingEdges = allEdges.filter((e) => e.target === targetNodeId); + const allSourcesArePlayerNodes = feedingEdges.every((e) => { + const srcNode = nodes.find((n) => n.id === e.source); + return srcNode?.type === "playerNode"; + }); + if (feedingEdges.length >= 2 && allSourcesArePlayerNodes) { + return "pickable"; + } + return "pending"; + } + return "default"; + } - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - LEFT_RING_COLOR, - { - x: ROUND_GAP * 2, - y: baseY + 4 * MATCH_GAP, - }, - "left", - ), - ); - }); + const winner = results[gameId]; - // Left finalist slot - nodes.push({ - id: `left-finalist`, - type: "emptySlot", - position: { x: ROUND_GAP * 3 + 23, y: 3.5 * MATCH_GAP }, - data: { text: "Finalist TBD", side: "left", ringColor: LEFT_RING_COLOR }, - }); + if (winner && playerId) { + return winner === playerId ? "winner" : "loser"; + } - // =========================================================================== - // RIGHT SIDE (second half of each round) - // =========================================================================== - const rightStartX = ROUND_GAP * 7; + // No result yet -- check if the target is an empty slot (pending/pickable) + const targetNode = nodes.find((n) => n.id === targetNodeId); + const targetIsEmpty = targetNode?.type === "emptySlot"; - // Round 1 - Right side (games 4-7) - round1.right.forEach((game, gameIndex) => { - const baseY = gameIndex * 2 * MATCH_GAP; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - RIGHT_RING_COLOR, - { - x: rightStartX, - y: baseY, - }, - "right", - "round1", - ), - ); + if (targetIsEmpty) { + const feedingEdges = allEdges.filter((e) => e.target === targetNodeId); + const allSourcesArePlayerNodes = feedingEdges.every((e) => { + const srcNode = nodes.find((n) => n.id === e.source); + return srcNode?.type === "playerNode"; + }); + if (feedingEdges.length >= 2 && allSourcesArePlayerNodes) { + return "pickable"; + } + return "pending"; + } - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - RIGHT_RING_COLOR, - { - x: rightStartX, - y: baseY + MATCH_GAP, - }, - "right", - "round1", - ), - ); - }); + return "default"; +} - // Quarterfinals - Right side (games 2-3) ROUND 2 - quarters.right.forEach((game, gameIndex) => { - const baseY = gameIndex * 4 * MATCH_GAP + MATCH_GAP * 0.64; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - RIGHT_RING_COLOR, - { - x: rightStartX - ROUND_GAP, - y: baseY, - }, - "right", - ), - ); +function computeEdgeHoverState( + edgeSource: string, + edgeTarget: string, + hoveredNodeId: string | null, + hoveredNodeType: "player" | "empty" | null, + edges: Edge[], +): EdgeHoverState { + if (!hoveredNodeId || !hoveredNodeType) return "none"; + + // Hovering an empty slot: highlight all edges flowing INTO it + if (hoveredNodeType === "empty") { + if (edgeTarget === hoveredNodeId) return "hovered-incoming"; + return "none"; + } - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - RIGHT_RING_COLOR, - { - x: rightStartX - ROUND_GAP, - y: baseY + 2 * MATCH_GAP, - }, - "right", - ), - ); - }); + // Hovering a player node: green ants on hovered player's edges, red ants on competitor + const hoveredEdges = edges.filter((e) => e.source === hoveredNodeId); + if (hoveredEdges.length === 0) return "none"; - // Semifinals - Right side (game 1) ROUND 3 - semis.right.forEach((game) => { - const baseY = 1.5 * MATCH_GAP; - - // Player 1 - nodes.push( - createNode( - `${game.id}-p1`, - game.player1, - game, - RIGHT_RING_COLOR, - { - x: rightStartX - ROUND_GAP * 2, - y: baseY, - }, - "right", - ), - ); + if (edgeSource === hoveredNodeId) return "hovered-pick"; - // Player 2 - nodes.push( - createNode( - `${game.id}-p2`, - game.player2, - game, - RIGHT_RING_COLOR, - { - x: rightStartX - ROUND_GAP * 2, - y: baseY + 4 * MATCH_GAP, - }, - "right", - ), + for (const hoveredEdge of hoveredEdges) { + const siblingEdges = edges.filter( + (e) => e.target === hoveredEdge.target && e.source !== hoveredNodeId, ); - }); - - // Right finalist slot - nodes.push({ - id: `right-finalist`, - type: "emptySlot", - position: { x: rightStartX - ROUND_GAP * 2.5, y: 3.5 * MATCH_GAP }, - data: { - text: "Finalist TBD", - side: "right", - ringColor: RIGHT_RING_COLOR, - }, - }); - - // =========================================================================== - // CHAMPIONSHIP (center) - // =========================================================================== - const finalGame = bracket.finals[0]; - // Center between left finalist (x=3) and right finalist (x=4.5) - nodes.push({ - id: "championship", - type: finalGame?.winner ? "playerNode" : "emptySlot", - position: { - x: ROUND_GAP * 3.75, - y: 0, - }, - data: finalGame?.winner - ? playerToNodeData(finalGame.winner, finalGame, "#FFD700", "left") - : { text: "CHAMPION", side: "left", ringColor: "#FFD700" }, - }); + if (siblingEdges.some((e) => e.source === edgeSource)) { + return "hovered-competitor"; + } + } - return nodes; + return "none"; } -// Edge style -const edgeStyle: React.CSSProperties = { - stroke: "#ffffff", - strokeWidth: 3, - filter: "drop-shadow(0px 0px 7px black)", -}; +interface EdgeGeneratorContext { + tournamentResults: Record; + predictions: Record; + showPicks: boolean; + nodes: Node[]; + hoveredNodeId: string | null; + hoveredNodeType: "player" | "empty" | null; +} -// Generate edges connecting the bracket -function generateEdges(): Edge[] { +function generateEdges(ctx: EdgeGeneratorContext): Edge[] { const edges: Edge[] = []; - - // Split each round into left/right halves const round1 = splitForDisplay(bracket.round1); const quarters = splitForDisplay(bracket.quarters); const semis = splitForDisplay(bracket.semis); - // =========================================================================== - // LEFT SIDE EDGES - // =========================================================================== + function pushEdge(edge: Omit) { + edges.push({ + ...edge, + data: { + state: "default" as EdgeState, + hoverState: "none" as EdgeHoverState, + }, + }); + } - // Round 1 to Quarters (left) + // LEFT SIDE EDGES round1.left.forEach((game, gameIndex) => { const quarterGame = quarters.left[Math.floor(gameIndex / 2)]; - // Player 1 to quarter game - edges.push({ + pushEdge({ id: `${game.id}-p1-to-${quarterGame.id}`, source: `${game.id}-p1`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-top", }); - // Player 2 to quarter game - edges.push({ + pushEdge({ id: `${game.id}-p2-to-${quarterGame.id}`, source: `${game.id}-p2`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, type: "bracket", - style: edgeStyle, targetHandle: "in-bottom", }); }); - // Quarters to Semis (left) quarters.left.forEach((game, gameIndex) => { const semiGame = semis.left[0]; - edges.push({ + pushEdge({ id: `${game.id}-p1-to-${semiGame.id}`, source: `${game.id}-p1`, target: `${semiGame.id}-p${gameIndex + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-${semiGame.id}`, source: `${game.id}-p2`, target: `${semiGame.id}-p${gameIndex + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-bottom", }); }); - // Semis to Left Finalist semis.left.forEach((game) => { - edges.push({ + pushEdge({ id: `${game.id}-p1-to-left-finalist`, source: `${game.id}-p1`, target: `left-finalist`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-left-finalist`, source: `${game.id}-p2`, target: `left-finalist`, type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-bottom", }); }); - // Left finalist to Championship - edges.push({ + pushEdge({ id: "left-finalist-to-champ", source: `left-finalist`, target: "championship", type: "bracket", - style: edgeStyle, sourceHandle: "out-right", targetHandle: "in-bottom", }); - // =========================================================================== // RIGHT SIDE EDGES - // =========================================================================== - - // Round 1 to Quarters (right) round1.right.forEach((game, gameIndex) => { const quarterGame = quarters.right[Math.floor(gameIndex / 2)]; - edges.push({ + pushEdge({ id: `${game.id}-p1-to-${quarterGame.id}`, source: `${game.id}-p1`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-${quarterGame.id}`, source: `${game.id}-p2`, target: `${quarterGame.id}-p${(gameIndex % 2) + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-bottom", }); }); - // Quarters to Semis (right) quarters.right.forEach((game, gameIndex) => { const semiGame = semis.right[0]; - edges.push({ + pushEdge({ id: `${game.id}-p1-to-${semiGame.id}`, source: `${game.id}-p1`, target: `${semiGame.id}-p${gameIndex + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-${semiGame.id}`, source: `${game.id}-p2`, target: `${semiGame.id}-p${gameIndex + 1}`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-bottom", }); }); - // Semis to Right Finalist semis.right.forEach((game) => { - edges.push({ + pushEdge({ id: `${game.id}-p1-to-right-finalist`, source: `${game.id}-p1`, target: `right-finalist`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-top", }); - edges.push({ + pushEdge({ id: `${game.id}-p2-to-right-finalist`, source: `${game.id}-p2`, target: `right-finalist`, type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-bottom", }); }); - // Right finalist to Championship - edges.push({ + pushEdge({ id: "right-finalist-to-champ", source: `right-finalist`, target: "championship", type: "bracket", - style: edgeStyle, sourceHandle: "out-left", targetHandle: "in-bottom", }); + // Pass 1: compute edge states (needs full edge list to check siblings) + for (const edge of edges) { + const state = computeEdgeState( + edge.source, + edge.target, + ctx.tournamentResults, + ctx.predictions, + ctx.showPicks, + ctx.nodes, + edges, + ); + (edge.data as { state: EdgeState }).state = state; + } + + // Pass 2: apply hover states + for (const edge of edges) { + const hoverState = computeEdgeHoverState( + edge.source, + edge.target, + ctx.hoveredNodeId, + ctx.hoveredNodeType, + edges, + ); + (edge.data as { hoverState: EdgeHoverState }).hoverState = hoverState; + } + return edges; } -const initialNodes = generateNodes(); -const initialEdges = generateEdges(); - const defaultEdgeOptions = { type: "bracket", - style: edgeStyle, }; -// Padding used for fitView const FIT_VIEW_PADDING = 0.05; -function BracketContent() { +function BracketContent({ + isInteractive = false, + predictions: propsPredictions, + onPick: propsOnPick, + isLocked: propsIsLocked, + isAuthenticated = false, + tournamentResults = {}, + showPicks = false, + onToggleShowPicks, +}: BracketProps) { + const ctx = usePredictionsContext(); + + // Use context if available, otherwise fall back to props + const predictions = ctx?.predictions ?? propsPredictions ?? {}; + const onPick = ctx?.setPrediction ?? propsOnPick; + const isLocked = ctx?.isLocked ?? propsIsLocked ?? false; + + const isPickingEnabled = isInteractive && isAuthenticated && !isLocked; + + const nodes = useMemo( + () => + generateNodes( + isInteractive, + predictions, + onPick, + isPickingEnabled, + tournamentResults, + showPicks, + isLocked, + ), + [ + isInteractive, + predictions, + onPick, + isPickingEnabled, + tournamentResults, + showPicks, + isLocked, + ], + ); + + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [hoveredNodeType, setHoveredNodeType] = useState< + "player" | "empty" | null + >(null); + + const edges = useMemo( + () => + generateEdges({ + tournamentResults, + predictions, + showPicks, + nodes, + hoveredNodeId, + hoveredNodeType, + }), + [ + tournamentResults, + predictions, + showPicks, + nodes, + hoveredNodeId, + hoveredNodeType, + ], + ); + + const styledNodes = useMemo(() => { + const nodeTypeMap = new Map(nodes.map((n) => [n.id, n.type])); + const pickableSlots = new Set(); + for (const [targetId, sourceIds] of EDGE_SOURCE_MAP) { + if ( + nodeTypeMap.get(targetId) === "emptySlot" && + sourceIds.length >= 2 && + sourceIds.every((id) => nodeTypeMap.get(id) === "playerNode") + ) { + pickableSlots.add(targetId); + } + } + return nodes.map((node) => { + if (node.type === "emptySlot" && !pickableSlots.has(node.id)) { + return { + ...node, + style: { ...node.style, filter: "brightness(0.5)" }, + }; + } + return node; + }); + }, [nodes]); const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(null); const rfInstanceRef = useRef(null); const boundsRef = useRef<{ width: number; height: number } | null>(null); - - // Scroll zoom lock state - prevents scroll trap const [scrollZoomLocked, setScrollZoomLocked] = useState(true); const unlockTimerRef = useRef | null>(null); @@ -645,7 +823,6 @@ function BracketContent() { }, []); const handleMouseEnter = useCallback(() => { - // Start 1.5s timer to unlock scroll zoom clearUnlockTimer(); unlockTimerRef.current = setTimeout(() => { setScrollZoomLocked(false); @@ -653,18 +830,10 @@ function BracketContent() { }, [clearUnlockTimer]); const handleMouseLeave = useCallback(() => { - // Lock scroll zoom and cancel any pending unlock clearUnlockTimer(); setScrollZoomLocked(true); }, [clearUnlockTimer]); - const handleClick = useCallback(() => { - // Instantly unlock on click - clearUnlockTimer(); - setScrollZoomLocked(false); - }, [clearUnlockTimer]); - - // Cleanup timer on unmount useEffect(() => { return () => clearUnlockTimer(); }, [clearUnlockTimer]); @@ -676,14 +845,8 @@ function BracketContent() { if (containerWidth === 0) return; const { width: contentWidth, height: contentHeight } = boundsRef.current; - - // Calculate the aspect ratio of the bracket content const aspectRatio = contentHeight / contentWidth; - - // Account for fitView padding const paddingMultiplier = 1 + FIT_VIEW_PADDING * 2; - - // Calculate height based on width and aspect ratio const scaledHeight = containerWidth * aspectRatio * paddingMultiplier; setContainerHeight(scaledHeight); @@ -691,100 +854,117 @@ function BracketContent() { const handleInit = (instance: ReactFlowInstance) => { rfInstanceRef.current = instance; - - // Get the bounds of all nodes (includes node dimensions) const nodes = instance.getNodes(); const bounds = getNodesBounds(nodes); - - // Store bounds for recalculation on resize boundsRef.current = { width: bounds.width, height: bounds.height }; - calculateHeight(); - - // Call fitView after a small delay to ensure nodes are fully rendered - requestAnimationFrame(() => { - instance.fitView({ padding: FIT_VIEW_PADDING }); - }); }; - // Re-fit view when height changes - useEffect(() => { - if (containerHeight && rfInstanceRef.current) { - // Small delay to let the DOM update with new height - const timer = setTimeout(() => { - rfInstanceRef.current?.fitView({ padding: FIT_VIEW_PADDING }); - }, 10); - return () => clearTimeout(timer); - } - }, [containerHeight]); - - // Recalculate height on window resize useEffect(() => { const handleResize = () => { calculateHeight(); + if (rfInstanceRef.current) { + rfInstanceRef.current.fitView({ padding: FIT_VIEW_PADDING }); + } }; - window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [calculateHeight]); + useEffect(() => { + if (containerHeight && rfInstanceRef.current) { + requestAnimationFrame(() => { + rfInstanceRef.current?.fitView({ padding: FIT_VIEW_PADDING }); + }); + } + }, [containerHeight]); + return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: this is a enhancement for mouse users. Feature still fully accessible. - // biome-ignore lint/a11y/noStaticElementInteractions: see above -
- {/* Debug indicator for scroll zoom lock state */} - {/*
- Scroll Zoom: {scrollZoomLocked ? "LOCKED" : "UNLOCKED"} -
*/} - + {onToggleShowPicks && ( + + )} + + {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse events for scroll/zoom unlock UX */} +
- - -
+ { + if (node.type === "emptySlot") { + setHoveredNodeId(node.id); + setHoveredNodeType("empty"); + return; + } + const data = node.data as { + prediction?: { interactionMode?: string }; + }; + if (data.prediction?.interactionMode === "pickable") { + setHoveredNodeId(node.id); + setHoveredNodeType("player"); + } + } + : undefined + } + onNodeMouseLeave={ + isInteractive + ? () => { + setHoveredNodeId(null); + setHoveredNodeType(null); + } + : undefined + } + onNodeClick={(_event, node) => { + const data = node.data as { + isPickable?: boolean; + gameId?: string; + playerId?: string; + onPick?: (gameId: string, playerId: string) => void; + }; + if ( + data.isPickable && + data.onPick && + data.gameId && + data.playerId + ) { + data.onPick(data.gameId, data.playerId); + } + }} + > + + +
+ + ); } -export function Bracket() { +export function Bracket(props: BracketProps) { const [mounted, setMounted] = useState(false); useEffect(() => { @@ -795,5 +975,5 @@ export function Bracket() { return
; } - return ; + return ; } diff --git a/src/components/bracket/PlayerNode.tsx b/src/components/bracket/PlayerNode.tsx index f1ee954..122f117 100644 --- a/src/components/bracket/PlayerNode.tsx +++ b/src/components/bracket/PlayerNode.tsx @@ -1,6 +1,56 @@ import { Handle, Position } from "@xyflow/react"; +import { memo } from "react"; import "./bracket.css"; +// User's pick state for this player in this game +export type PickState = + | { status: "noPick" } // No pick made for this game yet + | { status: "none" } // Opponent was picked (this is the unpicked option) + | { status: "pending" } // User picked this player, waiting for result + | { status: "correct" } // User picked this player and they won + | { status: "incorrect" }; // User picked this player and they lost + +// Interaction mode for this node +export type InteractionMode = "view" | "pickable"; + +// Tournament result state for this player +export type TournamentResult = "pending" | "winner" | "eliminated"; + +// Prediction options using structured types +export interface PredictionState { + pickState: PickState; + interactionMode: InteractionMode; + onPick?: (gameId: string, playerId: string) => void; +} + +// Helper to derive CSS class flags from structured types +function deriveClassFlags(prediction?: PredictionState): { + isSelected: boolean; + isCorrect: boolean; + isIncorrect: boolean; + isPickable: boolean; + isUnpicked: boolean; +} { + if (!prediction) { + return { + isSelected: false, + isCorrect: false, + isIncorrect: false, + isPickable: false, + isUnpicked: false, + }; + } + + const { pickState, interactionMode } = prediction; + return { + isSelected: pickState.status === "pending", + isCorrect: pickState.status === "correct", + isIncorrect: pickState.status === "incorrect", + isPickable: interactionMode === "pickable", + isUnpicked: pickState.status === "none", + }; +} + export interface PlayerData { photo: string; name: string; @@ -8,8 +58,14 @@ export interface PlayerData { ringColor?: string; isWinner?: boolean; isEliminated?: boolean; + isLoser?: boolean; side?: "left" | "right"; round?: "round1" | "later"; + showBio?: boolean; + prediction?: PredictionState; + playerId?: string; + gameId?: string; + youtubeUrl?: string; [key: string]: unknown; } @@ -20,8 +76,14 @@ interface PlayerNodeProps { ringColor?: string; isWinner?: boolean; isEliminated?: boolean; + isLoser?: boolean; side?: "left" | "right"; round?: "round1" | "later"; + showBio?: boolean; + prediction?: PredictionState; + playerId?: string; + gameId?: string; + youtubeUrl?: string; } export function PlayerNode({ @@ -31,50 +93,166 @@ export function PlayerNode({ ringColor = "var(--orange)", isWinner = false, isEliminated = false, + isLoser = false, side = "left", round = "later", + showBio = true, + prediction, + playerId, + gameId, + youtubeUrl, }: PlayerNodeProps) { + const { isSelected, isCorrect, isIncorrect, isPickable, isUnpicked } = + deriveClassFlags(prediction); + const onPick = prediction?.onPick; + const classNames = [ "player-node", isWinner && "player-node--winner", isEliminated && "player-node--eliminated", + isLoser && "player-node--loser", side === "right" && "player-node--right", round === "round1" && "player-node--round1", + isSelected && "player-node--selected", + isCorrect && "player-node--correct", + isIncorrect && "player-node--incorrect", + isPickable && "player-node--pickable", + isUnpicked && "player-node--unpicked", ] .filter(Boolean) .join(" "); + const handleClick = () => { + if (isPickable && onPick && gameId && playerId) { + onPick(gameId, playerId); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + (e.key === "Enter" || e.key === " ") && + isPickable && + onPick && + gameId && + playerId + ) { + e.preventDefault(); + onPick(gameId, playerId); + } + }; + return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: role is dynamically set to "button" when isPickable + // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label valid when role="button" is applied +
{name} + {isCorrect && ( +
+ + Correct pick + + +
+ )} + {isIncorrect && ( +
+ + Incorrect pick + + + +
+ )} + {isSelected && !isCorrect && !isIncorrect && ( +
+ + Your pick + + +
+ )}

{name}

-

{byline}

+ {showBio && byline &&

{byline}

} + {youtubeUrl && ( + e.stopPropagation()} + > + WATCH + + )}
); } -function Handles() { +function Handles({ round = "later" }: { round?: "round1" | "later" }) { + // Photo ring center: large=~47px, small=~30px from top + const handleOffset = round === "round1" ? 47 : 30; + return ( <> - +
); +}); + +function formatAirDate(isoDate: string): string { + const d = new Date(isoDate); + return d.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + }); +} + +function YouTubeIcon() { + return ( + + ); } // Empty slot for matches not yet played @@ -120,11 +331,15 @@ export function EmptySlot({ side = "left", ringColor, round = "later", + airDate, + youtubeUrl, }: { text?: string; side?: "left" | "right"; ringColor?: string; round?: "round1" | "later"; + airDate?: string; + youtubeUrl?: string; }) { const classNames = [ "player-node", @@ -149,13 +364,23 @@ export function EmptySlot({

{text || "TBD"}

+ {airDate && ( + + {formatAirDate(airDate)} + + )}
); } // React Flow wrapper for EmptySlot -export function EmptySlotFlow({ +export const EmptySlotFlow = memo(function EmptySlotFlow({ data, }: { data: { @@ -163,6 +388,8 @@ export function EmptySlotFlow({ side?: "left" | "right"; ringColor?: string; round?: "round1" | "later"; + airDate?: string; + youtubeUrl?: string; }; }) { return ( @@ -172,8 +399,10 @@ export function EmptySlotFlow({ side={data?.side} ringColor={data?.ringColor} round={data?.round} + airDate={data?.airDate} + youtubeUrl={data?.youtubeUrl} /> - + ); -} +}); diff --git a/src/components/bracket/SimpleBracket.tsx b/src/components/bracket/SimpleBracket.tsx deleted file mode 100644 index 1c46ab9..0000000 --- a/src/components/bracket/SimpleBracket.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { - addEdge, - applyEdgeChanges, - applyNodeChanges, - Position, - ReactFlow, -} from "@xyflow/react"; -import { useCallback, useState } from "react"; -import "@xyflow/react/dist/style.css"; - -const handles = [ - { - type: "source", - position: Position.Right, - id: "out", - }, - { - type: "target", - position: Position.Top, - id: "in", - }, -]; -const initialNodes = [ - { - id: "n1", - position: { x: 0, y: 0 }, - data: { label: "Node 1" }, - handles, - }, - - { id: "n2", position: { x: 0, y: 100 }, data: { label: "Node 2" }, handles }, - - { id: "n3", position: { x: 50, y: 50 }, data: { label: "Node 3" }, handles }, -]; -const initialEdges = [ - { id: "n1-n3", source: "n1", target: "n3" }, - { id: "n2-n3", source: "n2", target: "n3" }, -]; - -export function SimpleBracket() { - const [nodes, setNodes] = useState(initialNodes); - const [edges, setEdges] = useState(initialEdges); - - const onNodesChange = useCallback( - (changes) => - setNodes((nodesSnapshot) => applyNodeChanges(changes, nodesSnapshot)), - [], - ); - const onEdgesChange = useCallback( - (changes) => - setEdges((edgesSnapshot) => applyEdgeChanges(changes, edgesSnapshot)), - [], - ); - const onConnect = useCallback( - (params) => setEdges((edgesSnapshot) => addEdge(params, edgesSnapshot)), - [], - ); - - return ( -
- -
- ); -} diff --git a/src/components/bracket/bracket.css b/src/components/bracket/bracket.css index c621278..920f63d 100644 --- a/src/components/bracket/bracket.css +++ b/src/components/bracket/bracket.css @@ -1,3 +1,15 @@ +@property --ring-color { + syntax: ""; + inherits: true; + initial-value: #f97316; +} + +@property --inner-ring-color { + syntax: ""; + inherits: true; + initial-value: #facc15; +} + /* Player Node Base Styles */ .player-node { display: flex; @@ -9,40 +21,26 @@ /* Finalist and championship nodes - vertically stacked layout */ [data-id*="finalist"] &, [data-id="championship"] & { - flex-direction: column; + flex-direction: column !important; justify-content: center; align-items: center; - width: auto; + width: 176px; .player-name { - padding-top: 20px; + white-space: nowrap; } .player-info { text-align: center; margin-left: 0; + margin-right: 0; + padding: 30px 16px 10px 16px; + flex-direction: column; } .player-photo-ring { + margin-left: 0; + margin-right: 0; margin-bottom: -30px; } } - - [data-id="left-finalist"] & { - transform: translateX(10px) translateY(-5px); - } - - [data-id*="right-finalist"] & { - transform: translateX(-10px) translateY(-5px); - } - - /* Finalist sizing */ - [data-id*="finalist"] & .player-photo-ring { - --photo-size: 65px; - } - - /* Championship sizing */ - [data-id="championship"] & .player-photo-ring { - --photo-size: 80px; - transform: translateX(10px); - } } /* Smaller avatars for non-round1 matches */ @@ -50,10 +48,28 @@ --photo-size: 50px; } +.player-node:not(.player-node--round1) .player-photo { + --brain-size: 13px; + --circle-size: 29px; +} + /* Keep large avatars for finalists and champion */ -[data-id*="finalist"] .player-node .player-photo-ring, -[data-id*="champion"] .player-node .player-photo-ring { - --photo-size: 85px; +[data-id*="finalist"] .player-node .player-photo-ring { + --photo-size: 75px; +} + +[data-id*="finalist"] .player-node .player-photo { + --brain-size: 18px; + --circle-size: 40px; +} + +[data-id="championship"] .player-node .player-photo-ring { + --photo-size: 95px; +} + +[data-id="championship"] .player-node .player-photo { + --brain-size: 22px; + --circle-size: 50px; } .player-photo-ring { @@ -85,6 +101,11 @@ linear-gradient(var(--ring-color), var(--ring-color)) border-box; padding: var(--ring-thickness); flex-shrink: 0; + transition: + box-shadow 0.3s ease, + transform 0.3s ease, + --ring-color 0.3s ease, + --inner-ring-color 0.3s ease; } .player-photo-ring::before { @@ -94,6 +115,7 @@ border-radius: 50%; background: var(--inner-ring-color); padding: var(--gold-ring-thickness); + transition: background 0.3s ease; } .player-photo { @@ -103,8 +125,8 @@ width: 100%; height: calc(100% + var(--brain-size)); object-fit: cover; - filter: grayscale(10%); transform: translateY(calc(var(--brain-size) * -1)); + transform-origin: center bottom; --circle-size: 45px; /* Top 50% visible */ mask-image: @@ -121,6 +143,9 @@ mask-position: center center, center calc(var(--brain-size) / 2 + 0.5px); + transition: + filter 0.3s ease, + transform 0.3s ease; } /* Grey circle placeholder for empty slots */ @@ -154,6 +179,12 @@ flex: 1; min-width: 200px; border: 2px solid var(--black); + &:has(a.player-youtube-link) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 5px; + } } .player-name { @@ -162,9 +193,9 @@ color: var(--black); margin: 0; font-weight: 800; - line-height: 1.1; text-transform: uppercase; letter-spacing: 0.02em; + text-box: trim-both cap alphabetic; } .player-byline { @@ -175,6 +206,21 @@ white-space: nowrap; } +/* YouTube / air date links */ +.player-youtube-link { + display: inline-flex; + align-items: center; + gap: 3px; + color: black; + text-decoration: none; + font-family: var(--font-sans), sans-serif; + font-size: 12px; + transition: opacity 0.15s; + path { + color: red; + } +} + /* Winner State */ .player-node--winner .player-photo { filter: grayscale(0%); @@ -190,6 +236,16 @@ border-color: #888; } +/* Loser State - shows actual tournament losers with gray styling */ +.player-node--loser .player-photo-ring { + --ring-color: #666; + --inner-ring-color: #888; +} + +.player-node--loser .player-photo { + filter: grayscale(100%); +} + /* Empty Slot State - grey version of player node */ .player-node--empty .player-photo-ring { --ring-color: var(--yellow); @@ -226,43 +282,247 @@ text-align: center; } -/* React Flow Container */ -.bracket-container { - width: 100%; - min-height: 400px; /* Fallback minimum before JS calculates actual height */ - height: 1200px; /* Default that gets overridden by JS */ +/* Pickable State - hoverable/clickable for making predictions */ +.player-node--pickable { + cursor: pointer; } -.bracket-container .react-flow__node { - background: transparent; - border: none; - padding: 0; +.player-node--pickable:hover .player-photo-ring { + transform: scale(1.1); } -/* Edge/Connector Styles - Force visibility */ -.bracket-container .react-flow__edges { - z-index: 5; - pointer-events: none; +.player-node--pickable:focus { + outline: none; } -.bracket-container .react-flow__nodes { +.player-node--pickable:focus-visible .player-photo-ring { + box-shadow: + 0 0 0 3px var(--yellow), + 0 0 0 5px var(--black); +} + +/* Selected State - user has picked this player (checkmark badge) */ +.player-node--selected .player-photo { + filter: grayscale(0%); +} + +.player-node--selected .player-photo-ring { + --ring-color: var(--yellow); +} + +/* Unpicked State - opponent of a picked player (gray ring) */ +.player-node--unpicked .player-photo-ring { + --ring-color: #666; + --inner-ring-color: #888; +} + +.player-node--unpicked .player-photo { + filter: grayscale(100%); +} + +/* Checkmark entrance animation */ +@keyframes checkmark-pop { + 0% { + transform: scale(0); + opacity: 0; + } + 60% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.player-node__badge { + position: absolute; + top: -2px; + right: -2px; + width: 22px; + height: 22px; + border: 2px solid var(--black); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; z-index: 10; + animation: checkmark-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } -.bracket-container path { - filter: drop-shadow(0px 0px 7px black); +.player-node__badge--pending { + background: var(--yellow); + color: var(--black); } -.bracket-handle { - opacity: 0; /* Handles. Hidden, only used for debugging. */ - width: 10px; - height: 10px; - background: red; - border: 2px solid black; +.player-node__badge--correct { + --badge-correct: #22c55e; + background: var(--badge-correct); + color: var(--white); +} + +.player-node__badge--incorrect { + --badge-incorrect: #ef4444; + background: var(--badge-incorrect); + color: var(--white); +} + +@keyframes marching-ants { + 0% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -100%; + } +} + +.bracket-edge { + stroke: #fff; + + &.track { + stroke-opacity: 0.2; + filter: none; + } + + &.path { + stroke-opacity: 1; + } + + /* Animated states: marching ants */ + &[data-state="hover-pick"], + &[data-state="pickable"], + &[data-state="hover-competitor"] { + animation: marching-ants 4s linear infinite; + } + + /* Dashed stroke */ + &[data-state="hover-pick"].path, + &[data-state="pickable"].path, + &[data-state="pending"].path { + stroke-dasharray: 6 9; + } + + &[data-state="hover-competitor"].path { + stroke-opacity: 0.2; + } + + &[data-state="loser"].path { + stroke-opacity: 0.5; + } + + &[data-state="pending"] { + filter: brightness(0.5) !important; + } + + &[data-state="winner"].track { + stroke: var(--orange); + stroke-opacity: 0.8; + } + + &[data-state="hover-pick"] { + &.path { + stroke: var(--white); + } + &.track { + stroke: var(--orange); + stroke-opacity: 0.8; + } + } + + /* Standings mode overrides */ + .bracket-container--standings & { + &[data-state="pickable"], + &[data-state="hover-pick"], + &[data-state="hover-competitor"] { + animation: none; + } + + &[data-state="pending"] { + filter: none !important; + } + } +} + +.bracket-container--standings .react-flow__node { + filter: none !important; +} + +/* Bracket Toggle */ +.bracket-toggle { + display: flex; + justify-content: center; + gap: 0; + margin-bottom: 16px; + + button { + font-family: var(--font-block); + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 10px 24px; + border: 3px solid var(--black); + background: var(--beige); + color: var(--black); + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + box-shadow 0.15s, + transform 0.1s; + + &:first-child { + border-right: none; + } + + &.active { + background: var(--yellow); + color: var(--black); + box-shadow: 4px 4px 0 var(--black); + cursor: default; + } + + &:not(.active):hover { + background: var(--beige); + } + + &:not(.active):active { + transform: translate(2px, 2px); + } + &:has(.bracket-toggle-avatar) { + display: flex; + align-items: center; + gap: 2px; + } + } +} + +.bracket-toggle-avatar { + width: 40px; + height: 40px; border-radius: 50%; + border: 2px solid var(--orange); + vertical-align: middle; + margin-right: 6px; + margin-top: -2px; +} + +.bracket-toolbar { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; + max-width: 600px; + margin-inline: auto; +} + +.bracket-toolbar-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; } -/* Custom bracket controls styling */ .bracket-controls.react-flow__controls { top: 0; right: 0; @@ -278,22 +538,78 @@ color: var(--yellow); width: 32px; height: 32px; + + &:hover { + background: var(--yellow); + } + + svg { + fill: var(--yellow); + max-width: 14px; + max-height: 14px; + } + + &:hover svg { + fill: var(--black); + } } -.bracket-controls .react-flow__controls-button:hover { - background: var(--yellow); +.bracket-toggle-badge { + font-size: 0.625rem; + padding: 2px 6px; + margin-left: 6px; + border: 2px solid var(--black); + background: var(--white); + color: var(--black); + vertical-align: middle; + letter-spacing: 0.03em; } -.bracket-controls .react-flow__controls-button svg { - fill: var(--yellow); - max-width: 14px; - max-height: 14px; +.bracket-toggle-badge.locked { + background: var(--orange); + color: var(--white); + border-color: var(--orange); } -.bracket-controls .react-flow__controls-button:hover svg { - fill: var(--black); +/* React Flow Container */ +.bracket-container { + width: 100%; + min-height: 400px; /* Fallback minimum before JS calculates actual height */ + height: 1200px; /* Default that gets overridden by JS */ } +.bracket-container .react-flow__node { + background: transparent; + border: none; + padding: 0; +} + +/* Edge/Connector Styles - Force visibility */ +.bracket-container .react-flow__edges { + z-index: 5; + pointer-events: none; +} + +.bracket-container .react-flow__nodes { + z-index: 10; +} + +/* .bracket-container path:not(a path) { + filter: drop-shadow(0px 0px 7px black); +} */ + +.bracket-handle { + opacity: 0; /* Handles. Hidden, only used for debugging. */ + width: 10px; + height: 10px; + background: red; + border: 2px solid black; + border-radius: 50%; + z-index: 100; +} + +/* Custom bracket controls styling */ + .bracket-container .react-flow__attribution { background: var(--black); padding: 4px 8px; diff --git a/src/components/bracket/bracketTypes.ts b/src/components/bracket/bracketTypes.ts new file mode 100644 index 0000000..09528d9 --- /dev/null +++ b/src/components/bracket/bracketTypes.ts @@ -0,0 +1,29 @@ +export interface NodeContext { + hasResults: boolean; + tournamentResults: Record; + predictions: Record; + pickablePlayersCache: Record< + string, + [string | undefined, string | undefined] + >; + isInteractive: boolean; + isPickingEnabled: boolean; + showPicks: boolean; + isLocked: boolean; + onPick?: (gameId: string, playerId: string) => void; +} + +export interface RoundGeneratorOptions { + side: "left" | "right"; + ctx: NodeContext; +} + +// Layout constants +export const NODE_HEIGHT = 70; +export const VERTICAL_GAP = 76; +export const MATCH_GAP = NODE_HEIGHT + VERTICAL_GAP; // 146 +export const ROUND_GAP = 220; +export const RIGHT_START_X = ROUND_GAP * 7; // 2380 + +export const LEFT_RING_COLOR = "#f3370e"; +export const RIGHT_RING_COLOR = "#5CE1E6"; diff --git a/src/components/bracket/nodeGenerators.ts b/src/components/bracket/nodeGenerators.ts new file mode 100644 index 0000000..957d823 --- /dev/null +++ b/src/components/bracket/nodeGenerators.ts @@ -0,0 +1,518 @@ +import type { Node } from "@xyflow/react"; +import { + bracket, + FEEDER_GAMES, + GAME_LINKS, + type Game, + getAirDateForGame, + isLoser, + isWinner, + type Player, + players, + splitForDisplay, + YOUTUBE_CHANNEL, +} from "@/data/players"; +import { getPlayerById, getPlayersForGame } from "@/lib/simulation"; +import type { NodeContext, RoundGeneratorOptions } from "./bracketTypes"; +import { + LEFT_RING_COLOR, + MATCH_GAP, + RIGHT_RING_COLOR, + RIGHT_START_X, + ROUND_GAP, +} from "./bracketTypes"; +import type { InteractionMode, PickState, PredictionState } from "./PlayerNode"; + +function getRingColor(side: "left" | "right"): string { + return side === "left" ? LEFT_RING_COLOR : RIGHT_RING_COLOR; +} + +function getPhotoPath(player: Player): string { + const filename = player.photo.replace("/avatars/", ""); + return `/avatars/color/${filename}`; +} + +function playerToNodeData( + player: Player, + game: Game, + ringColor: string, + side: "left" | "right", + round: "round1" | "later" = "later", + options?: { + prediction?: PredictionState; + isLoser?: boolean; + showBio?: boolean; + tournamentResults?: Record; + }, +): { + photo: string; + name: string; + byline: string; + ringColor: string; + isWinner: boolean; + isEliminated: boolean; + isLoser: boolean; + showBio: boolean; + side: "left" | "right"; + round: "round1" | "later"; + prediction?: PredictionState; + playerId?: string; + gameId?: string; + youtubeUrl?: string; +} { + const isEliminated = + options?.isLoser !== undefined ? options.isLoser : isLoser(game, player); + const results = options?.tournamentResults ?? {}; + const playerIsWinner = + isWinner(game, player) || results[game.id] === player.id; + + let youtubeUrl: string | undefined; + if (!game.id.startsWith("r1-")) { + const feederGameId = getFeederGameForPlayer(game.id, player.id, results); + if (feederGameId) { + youtubeUrl = getYoutubeUrl(feederGameId); + } + } + + return { + photo: getPhotoPath(player), + name: player.name, + byline: player.byline, + ringColor, + isWinner: playerIsWinner, + isEliminated, + isLoser: isEliminated, + showBio: options?.showBio ?? true, + side, + round, + prediction: options?.prediction, + playerId: player.id, + gameId: game.id, + youtubeUrl, + }; +} + +function getYoutubeUrl(gameId: string): string { + const videoId = GAME_LINKS[gameId]; + if (videoId) return `https://www.youtube.com/watch?v=${videoId}`; + return YOUTUBE_CHANNEL; +} + +function getFeederGameForPlayer( + gameId: string, + playerId: string, + tournamentResults: Record, +): string | undefined { + const feeders = FEEDER_GAMES[gameId]; + if (!feeders) return undefined; + return feeders.find((fId) => tournamentResults[fId] === playerId); +} + +function createNode( + id: string, + player: Player | undefined, + game: Game, + ringColor: string, + position: { x: number; y: number }, + side: "left" | "right", + round: "round1" | "later" = "later", + emptyText?: string, + nodeOptions?: { + prediction?: PredictionState; + isLoser?: boolean; + showBio?: boolean; + }, + tournamentResults?: Record, +): Node { + if (player) { + return { + id, + type: "playerNode", + position, + data: playerToNodeData(player, game, ringColor, side, round, { + ...nodeOptions, + tournamentResults, + }), + }; + } + const airDate = getAirDateForGame(game.id); + return { + id, + type: "emptySlot", + position, + data: { + text: emptyText, + side, + ringColor, + round, + airDate, + youtubeUrl: getYoutubeUrl(game.id), + }, + }; +} + +function getPredictionOptions( + game: Game, + player: Player | undefined, + ctx: NodeContext, +): PredictionState | undefined { + // If not showing picks, don't return any pick state + if (!ctx.showPicks) { + return undefined; + } + + const userPick = ctx.predictions[game.id]; + const actualWinner = ctx.tournamentResults[game.id]; + + const defaults: PredictionState = { + pickState: { status: "noPick" }, + interactionMode: "view", + }; + + if (!player) { + return defaults; + } + + // Determine if this player can be picked + const pickablePlayers = ctx.pickablePlayersCache[game.id]; + const bothPlayersDetermined = + pickablePlayers[0] !== undefined && pickablePlayers[1] !== undefined; + const canPick = + ctx.isInteractive && + ctx.isPickingEnabled && + bothPlayersDetermined && + (player.id === pickablePlayers[0] || player.id === pickablePlayers[1]); + const interactionMode: InteractionMode = canPick ? "pickable" : "view"; + const onPick = canPick ? ctx.onPick : undefined; + + // Game has a result - determine correct/incorrect + if (actualWinner) { + if (ctx.isLocked && !ctx.showPicks) { + return defaults; + } + const isWinnerPlayer = actualWinner === player.id; + const wasPickedForThisGame = userPick === player.id; + + let pickState: PickState; + if (wasPickedForThisGame) { + pickState = isWinnerPlayer + ? { status: "correct" } + : { status: "incorrect" }; + } else { + pickState = { status: "none" }; + } + + return { pickState, interactionMode: "view" }; + } + + // No result yet - determine pending/none pick state + if (userPick) { + if (ctx.isLocked && !ctx.showPicks) { + return { pickState: { status: "noPick" }, interactionMode, onPick }; + } + const isPickedForThisGame = userPick === player.id; + const pickState: PickState = isPickedForThisGame + ? { status: "pending" } + : { status: "none" }; + return { pickState, interactionMode, onPick }; + } + + return { pickState: { status: "noPick" }, interactionMode, onPick }; +} + +function isPlayerLoser( + gameId: string, + playerId: string, + ctx: NodeContext, +): boolean { + if (!ctx.hasResults || ctx.showPicks) return false; + const winner = ctx.tournamentResults[gameId]; + if (!winner) return false; + return winner !== playerId; +} + +export function generateRound1Nodes({ + side, + ctx, +}: RoundGeneratorOptions): Node[] { + const nodes: Node[] = []; + const round1 = splitForDisplay(bracket.round1); + const games = side === "left" ? round1.left : round1.right; + const ringColor = getRingColor(side); + const xPos = side === "left" ? 0 : RIGHT_START_X; + + games.forEach((game, gameIndex) => { + const baseY = gameIndex * 2 * MATCH_GAP; + const player1 = game.player1; + const player2 = game.player2; + + const p1Options = getPredictionOptions(game, player1, ctx); + const p1Loser = player1 ? isPlayerLoser(game.id, player1.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p1`, + player1, + game, + ringColor, + { x: xPos, y: baseY }, + side, + "round1", + undefined, + { prediction: p1Options, showBio: true, isLoser: p1Loser }, + ctx.tournamentResults, + ), + ); + + const p2Options = getPredictionOptions(game, player2, ctx); + const p2Loser = player2 ? isPlayerLoser(game.id, player2.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p2`, + player2, + game, + ringColor, + { x: xPos, y: baseY + MATCH_GAP }, + side, + "round1", + undefined, + { prediction: p2Options, showBio: true, isLoser: p2Loser }, + ctx.tournamentResults, + ), + ); + }); + + return nodes; +} + +export function generateQuarterNodes({ + side, + ctx, +}: RoundGeneratorOptions): Node[] { + const nodes: Node[] = []; + const quarters = splitForDisplay(bracket.quarters); + const games = side === "left" ? quarters.left : quarters.right; + const ringColor = getRingColor(side); + const xPos = side === "left" ? ROUND_GAP : RIGHT_START_X - ROUND_GAP; + + games.forEach((game, gameIndex) => { + const qfOffset = MATCH_GAP * 0.637; + const baseY = gameIndex * 4 * MATCH_GAP + qfOffset; + + let player1: Player | undefined; + let player2: Player | undefined; + if (ctx.showPicks) { + const pickablePlayers = ctx.pickablePlayersCache[game.id]; + player1 = pickablePlayers[0] + ? players.find((p) => p.id === pickablePlayers[0]) + : undefined; + player2 = pickablePlayers[1] + ? players.find((p) => p.id === pickablePlayers[1]) + : undefined; + } else if (ctx.hasResults) { + const [p1Id, p2Id] = getPlayersForGame(game.id, ctx.tournamentResults); + player1 = p1Id ? getPlayerById(p1Id) : undefined; + player2 = p2Id ? getPlayerById(p2Id) : undefined; + } else { + player1 = game.player1; + player2 = game.player2; + } + + const p1Options = getPredictionOptions(game, player1, ctx); + const p1Loser = player1 ? isPlayerLoser(game.id, player1.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p1`, + player1, + game, + ringColor, + { x: xPos, y: baseY }, + side, + "later", + "Quarter Finalist", + { prediction: p1Options, showBio: false, isLoser: p1Loser }, + ctx.tournamentResults, + ), + ); + + const p2Options = getPredictionOptions(game, player2, ctx); + const p2Loser = player2 ? isPlayerLoser(game.id, player2.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p2`, + player2, + game, + ringColor, + { x: xPos, y: baseY + 2 * MATCH_GAP }, + side, + "later", + "Quarter Finalist", + { prediction: p2Options, showBio: false, isLoser: p2Loser }, + ctx.tournamentResults, + ), + ); + }); + + return nodes; +} + +export function generateSemiNodes({ + side, + ctx, +}: RoundGeneratorOptions): Node[] { + const nodes: Node[] = []; + const semis = splitForDisplay(bracket.semis); + const games = side === "left" ? semis.left : semis.right; + const ringColor = getRingColor(side); + const xPos = side === "left" ? ROUND_GAP * 2 : RIGHT_START_X - ROUND_GAP * 2; + + games.forEach((game) => { + const baseY = 1.5 * MATCH_GAP; + + let player1: Player | undefined; + let player2: Player | undefined; + if (ctx.showPicks) { + const pickablePlayers = ctx.pickablePlayersCache[game.id]; + player1 = pickablePlayers[0] + ? players.find((p) => p.id === pickablePlayers[0]) + : undefined; + player2 = pickablePlayers[1] + ? players.find((p) => p.id === pickablePlayers[1]) + : undefined; + } else if (ctx.hasResults) { + const [p1Id, p2Id] = getPlayersForGame(game.id, ctx.tournamentResults); + player1 = p1Id ? getPlayerById(p1Id) : undefined; + player2 = p2Id ? getPlayerById(p2Id) : undefined; + } else { + player1 = game.player1; + player2 = game.player2; + } + + const p1Options = getPredictionOptions(game, player1, ctx); + const p1Loser = player1 ? isPlayerLoser(game.id, player1.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p1`, + player1, + game, + ringColor, + { x: xPos, y: baseY }, + side, + "later", + "Semi Finalist", + { prediction: p1Options, showBio: false, isLoser: p1Loser }, + ctx.tournamentResults, + ), + ); + + const p2Options = getPredictionOptions(game, player2, ctx); + const p2Loser = player2 ? isPlayerLoser(game.id, player2.id, ctx) : false; + nodes.push( + createNode( + `${game.id}-p2`, + player2, + game, + ringColor, + { x: xPos, y: baseY + 4 * MATCH_GAP }, + side, + "later", + "Semi Finalist", + { prediction: p2Options, showBio: false, isLoser: p2Loser }, + ctx.tournamentResults, + ), + ); + }); + + return nodes; +} + +export function generateFinalistNode({ + side, + ctx, +}: RoundGeneratorOptions): Node { + const finalGame = bracket.finals[0]; + const ringColor = getRingColor(side); + + let finalist: Player | undefined; + const sfGameId = side === "left" ? "sf-0" : "sf-1"; + + if (ctx.showPicks) { + const finalistId = ctx.predictions[sfGameId]; + finalist = finalistId + ? players.find((p) => p.id === finalistId) + : undefined; + } else if (ctx.hasResults) { + const [p1Id, p2Id] = getPlayersForGame("final", ctx.tournamentResults); + finalist = + side === "left" + ? p1Id + ? getPlayerById(p1Id) + : undefined + : p2Id + ? getPlayerById(p2Id) + : undefined; + } else { + finalist = side === "left" ? finalGame.player1 : finalGame.player2; + } + + const finalistOptions = getPredictionOptions(finalGame, finalist, ctx); + const finalistLoser = finalist + ? isPlayerLoser("final", finalist.id, ctx) + : false; + + const xPos = + side === "left" ? ROUND_GAP * 3 + 23 : RIGHT_START_X - ROUND_GAP * 2.5 - 23; + + return createNode( + `${side}-finalist`, + finalist, + finalGame, + ringColor, + { x: xPos, y: 3.5 * MATCH_GAP }, + side, + "later", + "Finalist", + { prediction: finalistOptions, showBio: false, isLoser: finalistLoser }, + ctx.tournamentResults, + ); +} + +export function generateChampionshipNode(ctx: NodeContext): Node { + const finalGame = bracket.finals[0]; + + let champion: Player | undefined; + if (ctx.showPicks) { + const championId = ctx.predictions.final; + champion = championId + ? players.find((p) => p.id === championId) + : undefined; + } else if (ctx.hasResults && ctx.tournamentResults.final) { + champion = getPlayerById(ctx.tournamentResults.final); + } else { + champion = finalGame.winner; + } + + return { + id: "championship", + type: champion ? "playerNode" : "emptySlot", + position: { + x: ROUND_GAP * 3.75, + y: 0, + }, + data: champion + ? { + ...playerToNodeData(champion, finalGame, "#FFD700", "left", "later", { + isLoser: false, + showBio: false, + tournamentResults: ctx.tournamentResults, + }), + youtubeUrl: getYoutubeUrl("final"), + } + : { + text: "CHAMPION", + side: "left", + ringColor: "#FFD700", + airDate: getAirDateForGame("final"), + youtubeUrl: getYoutubeUrl("final"), + }, + }; +} diff --git a/src/components/leaderboard/Activity.tsx b/src/components/leaderboard/Activity.tsx new file mode 100644 index 0000000..1964376 --- /dev/null +++ b/src/components/leaderboard/Activity.tsx @@ -0,0 +1,231 @@ +import { useQuery } from "@tanstack/react-query"; +import { createServerFn } from "@tanstack/react-start"; +import { useEffect, useRef, useState } from "react"; +import "./activity.css"; + +type ActivityItem = { + key: string; + pickerName: string; + pickerImage: string | null; + pickerUsername: string | null; + predictedName: string; + predictedPhoto: string; + opponentName: string | null; + opponentPhoto: string | null; + label: string; +}; + +const getRecentPickers = createServerFn({ method: "GET" }).handler(async () => { + const { env } = await import("cloudflare:workers"); + const { createDb } = await import("@/db"); + const { desc, eq, inArray } = await import("drizzle-orm"); + const schema = await import("@/db/schema"); + const { bracket, players } = await import("@/data/players"); + + const playerMap = new Map(players.map((p) => [p.id, p])); + + const r1Opponents = new Map>(); + for (const game of bracket.round1) { + if (game.player1 && game.player2) { + const opponents = new Map(); + opponents.set(game.player1.id, game.player2.id); + opponents.set(game.player2.id, game.player1.id); + r1Opponents.set(game.id, opponents); + } + } + + function roundLabel(gameId: string): string { + if (gameId === "final") return "win it all"; + if (gameId.startsWith("sf-")) return "win the semis"; + if (gameId.startsWith("qf-")) return "win the quarters"; + return ""; + } + + const db = createDb(env.DB); + + const lockedUsers = await db + .select({ + userId: schema.userBracketStatus.userId, + userName: schema.user.name, + userImage: schema.user.image, + username: schema.user.username, + }) + .from(schema.userBracketStatus) + .innerJoin(schema.user, eq(schema.userBracketStatus.userId, schema.user.id)) + .where(eq(schema.userBracketStatus.isLocked, true)) + .orderBy(desc(schema.userBracketStatus.lockedAt)) + .limit(40); + + if (lockedUsers.length === 0) { + return []; + } + + const userIds = lockedUsers.map((u) => u.userId); + const allPredictions = await db + .select({ + userId: schema.userPrediction.userId, + gameId: schema.userPrediction.gameId, + predictedWinnerId: schema.userPrediction.predictedWinnerId, + }) + .from(schema.userPrediction) + .where(inArray(schema.userPrediction.userId, userIds)); + + const predictionsByUser = new Map< + string, + { gameId: string; predictedWinnerId: string }[] + >(); + for (const p of allPredictions) { + const list = predictionsByUser.get(p.userId) || []; + list.push({ + gameId: p.gameId, + predictedWinnerId: p.predictedWinnerId, + }); + predictionsByUser.set(p.userId, list); + } + + const items: ActivityItem[] = []; + + for (const user of lockedUsers) { + const predictions = predictionsByUser.get(user.userId) || []; + if (predictions.length === 0) continue; + + const shuffled = [...predictions]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + for (const pred of shuffled.slice(0, 10)) { + const predicted = playerMap.get(pred.predictedWinnerId); + if (!predicted) continue; + + let opponentName: string | null = null; + let opponentPhoto: string | null = null; + const gameOpponents = r1Opponents.get(pred.gameId); + if (gameOpponents) { + const oppId = gameOpponents.get(pred.predictedWinnerId); + const opp = oppId ? playerMap.get(oppId) : undefined; + if (opp) { + opponentName = opp.name; + opponentPhoto = `/avatars/color/${opp.id}.png`; + } + } + + items.push({ + key: `${user.username ?? user.userName}-${pred.gameId}`, + pickerName: user.userName, + pickerImage: user.userImage, + pickerUsername: user.username, + predictedName: predicted.name, + predictedPhoto: `/avatars/color/${predicted.id}.png`, + opponentName, + opponentPhoto, + label: gameOpponents ? "" : roundLabel(pred.gameId), + }); + } + } + + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + + return items; +}); + +const MAX_VISIBLE = 6; +const TICK_INTERVAL = 2500; + +function PickSentence({ item }: { item: ActivityItem }) { + const inner = ( + <> + {item.pickerImage && ( + + )} + {item.pickerName} + {" picks "} + + {item.predictedName} + {item.opponentName ? ( + <> + {" to beat "} + {item.opponentPhoto && ( + + )} + {item.opponentName} + + ) : ( + <>{` to ${item.label}`} + )} + + ); + + if (item.pickerUsername) { + return ( + + {inner} + + ); + } + return {inner}; +} + +export function Activity() { + const { data } = useQuery({ + queryKey: ["recent-pickers"], + queryFn: () => getRecentPickers(), + staleTime: 1000 * 60 * 5, + }); + + const [visible, setVisible] = useState([]); + const queueRef = useRef([]); + const tickRef = useRef | null>(null); + + useEffect(() => { + if (!data || data.length === 0) return; + + queueRef.current = [...data]; + + const initial: ActivityItem[] = []; + for (let i = 0; i < 3 && queueRef.current.length > 0; i++) { + const item = queueRef.current.shift()!; + queueRef.current.push(item); + initial.push(item); + } + setVisible(initial); + + function tick() { + const queue = queueRef.current; + const next = queue.shift(); + if (!next) return; + queue.push(next); + + setVisible((prev) => { + const updated = [next, ...prev]; + if (updated.length > MAX_VISIBLE) { + return updated.slice(0, MAX_VISIBLE); + } + return updated; + }); + } + + tickRef.current = setInterval(tick, TICK_INTERVAL); + return () => { + if (tickRef.current) clearInterval(tickRef.current); + }; + }, [data]); + + if (visible.length === 0) return null; + + return ( +
+
    + {visible.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/src/components/leaderboard/Leaderboard.tsx b/src/components/leaderboard/Leaderboard.tsx new file mode 100644 index 0000000..8ff54e5 --- /dev/null +++ b/src/components/leaderboard/Leaderboard.tsx @@ -0,0 +1,145 @@ +import { useQuery } from "@tanstack/react-query"; +import { createServerFn } from "@tanstack/react-start"; +import { LeaderboardScore } from "./LeaderboardScore"; +import "./leaderboard.css"; + +type LeaderboardEntry = { + rank: number; + userId: string; + userName: string; + userImage: string | null; + username: string | null; + round1Score: number; + round2Score: number; + round3Score: number; + round4Score: number; + totalScore: number; +}; + +const getLeaderboard = createServerFn({ method: "GET" }).handler(async () => { + const { env } = await import("cloudflare:workers"); + const { createDb } = await import("@/db"); + const { desc, eq } = await import("drizzle-orm"); + const schema = await import("@/db/schema"); + + const db = createDb(env.DB); + + const scores = await db + .select({ + userId: schema.userScore.userId, + round1Score: schema.userScore.round1Score, + round2Score: schema.userScore.round2Score, + round3Score: schema.userScore.round3Score, + round4Score: schema.userScore.round4Score, + totalScore: schema.userScore.totalScore, + userName: schema.user.name, + userImage: schema.user.image, + username: schema.user.username, + }) + .from(schema.userScore) + .innerJoin(schema.user, eq(schema.userScore.userId, schema.user.id)) + .orderBy(desc(schema.userScore.totalScore)) + .limit(100); + + return scores.map( + (score, index): LeaderboardEntry => ({ + rank: index + 1, + userId: score.userId, + userName: score.userName, + userImage: score.userImage, + username: score.username, + round1Score: score.round1Score, + round2Score: score.round2Score, + round3Score: score.round3Score, + round4Score: score.round4Score, + totalScore: score.totalScore, + }), + ); +}); + +export function Leaderboard() { + const { data, isLoading } = useQuery({ + queryKey: ["leaderboard"], + queryFn: () => getLeaderboard(), + staleTime: 1000 * 60 * 5, + }); + const entries = data ?? []; + + return ( +
+
+

The Leaderboard

+
+
+
+
+
+
+ +

Top Predictors

+ + {isLoading ? ( +
Loading...
+ ) : entries.length === 0 ? ( +
+

No scores yet. Lock your bracket and check back after Round 1!

+
+ ) : ( + + + + + + + + + + {entries.map((entry) => ( + + + + + + ))} + +
#PlayerTotal
{entry.rank} + {entry.username ? ( + + {entry.userImage && ( + + )} + + {entry.userName} + + + ) : ( +
+ {entry.userImage && ( + + )} + + {entry.userName} + +
+ )} +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/leaderboard/LeaderboardScore.tsx b/src/components/leaderboard/LeaderboardScore.tsx new file mode 100644 index 0000000..53137c0 --- /dev/null +++ b/src/components/leaderboard/LeaderboardScore.tsx @@ -0,0 +1,21 @@ +export function LeaderboardScore({ + value, + isTotal, +}: { + value: number; + isTotal?: boolean; +}) { + const digits = String(value).padStart(isTotal ? 3 : 2, "0"); + return ( +
+ {digits.split("").map((d, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Digits don't reorder and spans are stateless + + {d} + + ))} +
+ ); +} diff --git a/src/components/leaderboard/activity.css b/src/components/leaderboard/activity.css new file mode 100644 index 0000000..18329fe --- /dev/null +++ b/src/components/leaderboard/activity.css @@ -0,0 +1,89 @@ +.activity { + max-width: 600px; + margin: 0 auto; + padding: 10px 16px; + height: 155px; + mask-image: linear-gradient( + to bottom, + transparent, + black 5%, + black 95%, + transparent + ); +} + +.activity-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + gap: 4px; +} + +.activity-item { + background: var(--black); + border-radius: 500px; + opacity: 1; + transform: translateY(0); + max-height: 60px; + transition: + max-height 0.5s ease, + opacity 0.5s ease, + transform 0.5s ease; + + @starting-style { + max-height: 0; + opacity: 0; + transform: translateY(-10px); + } +} + +.pick-sentence { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + padding: 6px 10px; + font-family: var(--font-sans); + font-size: 0.825rem; + color: white; + text-decoration: none; + transition: color 0.15s; +} + +a.pick-sentence:hover { + color: var(--white); +} + +.pick-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid #555; + object-fit: cover; + flex-shrink: 0; +} + +.pick-user-name { + color: var(--white); + font-weight: 600; +} + +.pick-player-name { + color: var(--yellow); + font-weight: 600; +} + +@media (max-width: 600px) { + .pick-sentence { + font-size: 0.75rem; + } + + .pick-avatar { + width: 18px; + height: 18px; + } +} diff --git a/src/components/leaderboard/leaderboard.css b/src/components/leaderboard/leaderboard.css new file mode 100644 index 0000000..30863f7 --- /dev/null +++ b/src/components/leaderboard/leaderboard.css @@ -0,0 +1,277 @@ +.leaderboard-section { + padding-block: 40px 80px; + background: transparent; + background-image: none; +} + +.leaderboard-wrapper { + max-width: 800px; + margin: 0 auto; + filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.5)); +} + +.leaderboard-frame { + --bolt-size: 16px; + position: relative; + background: var(--yellow); + border: 6px solid var(--black); + border-radius: 8px; + padding: 32px; +} + +/* Corner bolts */ +.leaderboard-bolt { + position: absolute; + width: var(--bolt-size); + height: var(--bolt-size); + background: #444; + border-radius: 50%; + border: 2px solid #222; + box-shadow: + inset 1px 1px 2px rgba(255, 255, 255, 0.3), + inset -1px -1px 2px rgba(0, 0, 0, 0.3); +} + +.leaderboard-bolt::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(45deg); + width: 60%; + height: 2px; + background: #222; +} + +.leaderboard-bolt--tl { + top: 8px; + left: 8px; +} +.leaderboard-bolt--tr { + top: 8px; + right: 8px; +} +.leaderboard-bolt--bl { + bottom: 8px; + left: 8px; +} +.leaderboard-bolt--br { + bottom: 8px; + right: 8px; +} + +.leaderboard-title { + font-family: var(--font-block); + font-size: 2rem; + text-transform: uppercase; + color: var(--black); + text-align: center; + margin-bottom: 16px; + letter-spacing: 2px; +} + +.leaderboard-table { + width: 100%; + border-collapse: collapse; + background: #1a1a1a; + border-radius: 4px; + overflow: hidden; +} + +.leaderboard-table th, +.leaderboard-table td { + padding: 10px 8px; + text-align: center; + border-bottom: 2px solid #333; +} + +.leaderboard-table th { + background: #222; + color: var(--yellow); + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; +} + +.leaderboard-table th:nth-child(2) { + text-align: left; +} + +.leaderboard-table th:nth-child(3) { + text-align: right; + padding-right: 16px; +} + +.leaderboard-table tbody tr { + transition: background-color 0.15s ease; +} + +.leaderboard-table tbody tr:hover { + background: #252525; +} + +.leaderboard-table tbody tr:last-child td { + border-bottom: none; +} + +/* Rank column */ +.leaderboard-rank { + font-family: var(--font-block); + font-size: 1.25rem; + color: var(--yellow); + width: 50px; +} + +/* Top 3 special styling */ +.leaderboard-table tbody tr:nth-child(1) .leaderboard-rank { + color: #ffd700; +} +.leaderboard-table tbody tr:nth-child(2) .leaderboard-rank { + color: #c0c0c0; +} +.leaderboard-table tbody tr:nth-child(3) .leaderboard-rank { + color: #cd7f32; +} + +/* Player cell with avatar */ +.leaderboard-player { + display: flex; + align-items: center; + gap: 10px; + text-align: left; +} + +.leaderboard-player--link { + text-decoration: none; + border-radius: 4px; + padding: 4px 8px; + margin: -4px -8px; + transition: background-color 0.15s ease; +} + +.leaderboard-player--link:hover { + background: rgba(255, 174, 0, 0.15); +} + +.leaderboard-player--link:hover .leaderboard-name { + color: var(--yellow); +} + +.leaderboard-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid var(--yellow); + object-fit: cover; + flex-shrink: 0; +} + +.leaderboard-name { + font-family: var(--font-sans); + font-weight: 500; + color: var(--white); + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; +} + +/* Total column */ +.leaderboard-table td.leaderboard-total { + text-align: right; + padding-right: 16px; +} + +/* LED digit styles - individual boxes with "8" outline */ +.leaderboard-digits { + display: inline-flex; + gap: 2px; +} + +.leaderboard-digit { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 40px; + background: #0a0a0a; + border: 2px solid var(--black); + box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.9); + font-family: "DSEG7Classic", monospace; + font-size: 1.4rem; + font-weight: bold; + color: var(--orange); + text-shadow: 0 0 10px rgba(243, 55, 14, 0.7); +} + +/* Inactive segment "8" outline */ +.leaderboard-digit::before { + content: "8"; + position: absolute; + color: rgba(243, 55, 14, 0.1); + text-shadow: none; +} + +/* Total column - green digits */ +.leaderboard-digits--total .leaderboard-digit { + color: #33ff33; + text-shadow: 0 0 10px rgba(51, 255, 51, 0.7); +} + +.leaderboard-digits--total .leaderboard-digit::before { + color: rgba(51, 255, 51, 0.1); +} + +/* Empty state */ +.leaderboard-empty { + text-align: center; + padding: 40px 20px; + color: #666; + font-style: italic; + + p { + margin: 0; + } +} + +/* Mobile responsiveness */ +@media (max-width: 600px) { + .leaderboard-frame { + padding: 16px 24px; + } + + .leaderboard-title { + font-size: 1.5rem; + } + + .leaderboard-table th, + .leaderboard-table td { + padding: 8px 4px; + font-size: 0.8rem; + } + + .leaderboard-digit { + width: 20px; + height: 30px; + font-size: 1rem; + } + + .leaderboard-avatar { + width: 28px; + height: 28px; + } + + .leaderboard-name { + font-size: 0.85rem; + max-width: 120px; + } + + .leaderboard-table td.leaderboard-total, + .leaderboard-table th:nth-child(3) { + padding-right: 8px; + } +} diff --git a/src/components/scoreboard/Scoreboard.tsx b/src/components/scoreboard/Scoreboard.tsx new file mode 100644 index 0000000..a440b61 --- /dev/null +++ b/src/components/scoreboard/Scoreboard.tsx @@ -0,0 +1,32 @@ +import type { CountdownTime } from "@/hooks/useCountdown"; +import { ScoreboardSeparator } from "./ScoreboardSeparator"; +import { ScoreboardUnit } from "./ScoreboardUnit"; + +export function Scoreboard({ + countdown, + isUrgent, +}: { + countdown: CountdownTime; + isUrgent: boolean; +}) { + return ( +
+
+
+
+
+
+
+ + + + + + + +
+
+
+
+ ); +} diff --git a/src/components/scoreboard/ScoreboardSeparator.tsx b/src/components/scoreboard/ScoreboardSeparator.tsx new file mode 100644 index 0000000..1bd8036 --- /dev/null +++ b/src/components/scoreboard/ScoreboardSeparator.tsx @@ -0,0 +1,8 @@ +export function ScoreboardSeparator() { + return ( +
+ + +
+ ); +} diff --git a/src/components/scoreboard/ScoreboardUnit.tsx b/src/components/scoreboard/ScoreboardUnit.tsx new file mode 100644 index 0000000..8246301 --- /dev/null +++ b/src/components/scoreboard/ScoreboardUnit.tsx @@ -0,0 +1,18 @@ +export function ScoreboardUnit({ + value, + label, +}: { + value: number; + label: string; +}) { + const digits = String(value).padStart(2, "0"); + return ( +
+
+ {digits[0]} + {digits[1]} +
+ {label} +
+ ); +} diff --git a/src/context/PredictionsContext.tsx b/src/context/PredictionsContext.tsx new file mode 100644 index 0000000..625fd3f --- /dev/null +++ b/src/context/PredictionsContext.tsx @@ -0,0 +1,28 @@ +import { createContext, type ReactNode, use } from "react"; +import { + type UsePredictionsReturn, + usePredictions, +} from "@/hooks/usePredictions"; + +const PredictionsContext = createContext(null); + +export function PredictionsProvider({ + isAuthenticated, + userId, + children, +}: { + isAuthenticated: boolean; + userId?: string; + children: ReactNode; +}) { + const predictions = usePredictions(isAuthenticated, userId); + return ( + + {children} + + ); +} + +export function usePredictionsContext() { + return use(PredictionsContext); +} diff --git a/src/data/players.ts b/src/data/players.ts index ac64229..9743b06 100644 --- a/src/data/players.ts +++ b/src/data/players.ts @@ -1,3 +1,86 @@ +// ============================================================================= +// TOURNAMENT CONFIG +// ============================================================================= + +// Deadline for locking brackets (ISO 8601 format) +// After this time, no new brackets can be created or locked +export const BRACKET_DEADLINE = "2026-02-28T00:00:00Z"; + +// Game schedule - when results will be announced for each round +export const GAME_SCHEDULE = { + "left-r1": "2026-03-06T13:00:00Z", + "right-r1": "2026-03-13T12:00:00Z", + qf: "2026-03-20T12:00:00Z", + sf: "2026-03-27T12:00:00Z", + final: "2026-04-03T12:00:00Z", +} as const; + +export type ScheduleKey = keyof typeof GAME_SCHEDULE; + +export function getScheduleKeyForGame(gameId: string): ScheduleKey { + if (gameId.startsWith("r1-")) { + const idx = Number.parseInt(gameId.split("-")[1], 10); + return idx < 4 ? "left-r1" : "right-r1"; + } + if (gameId.startsWith("qf-")) return "qf"; + if (gameId.startsWith("sf-")) return "sf"; + return "final"; +} + +export function getAirDateForGame(gameId: string): string { + return GAME_SCHEDULE[getScheduleKeyForGame(gameId)]; +} + +export const GAME_LINKS: Record = { + // YouTube video IDs keyed by game ID + // "r1-0": "dQw4w9WgXcQ", +}; + +export const YOUTUBE_CHANNEL = "https://www.youtube.com/@syntaxfm"; + +// Get the next upcoming game time (or null if all games are done) +export function getNextGameTime(): { round: string; time: string } | null { + const now = Date.now(); + const rounds = Object.entries(GAME_SCHEDULE) as [string, string][]; + for (const [round, time] of rounds) { + if (new Date(time).getTime() > now) { + return { round, time }; + } + } + return null; +} + +// Total number of games in the bracket (8 + 4 + 2 + 1 = 15) +export const TOTAL_GAMES = 15; + +// Game IDs by round +export const ROUND_1_GAME_IDS = [ + "r1-0", + "r1-1", + "r1-2", + "r1-3", + "r1-4", + "r1-5", + "r1-6", + "r1-7", +] as const; + +export const QUARTER_GAME_IDS = ["qf-0", "qf-1", "qf-2", "qf-3"] as const; + +export const SEMI_GAME_IDS = ["sf-0", "sf-1"] as const; + +export const FINAL_GAME_IDS = ["final"] as const; + +// All game IDs in tournament order +export const ALL_GAME_IDS = [ + ...ROUND_1_GAME_IDS, + ...QUARTER_GAME_IDS, + ...SEMI_GAME_IDS, + ...FINAL_GAME_IDS, +] as const; + +export type GameId = (typeof ALL_GAME_IDS)[number]; + // ============================================================================= // PLAYER DEFINITIONS // ============================================================================= @@ -219,28 +302,28 @@ export const bracket: Bracket = { // Game 0: Jason Lengstorf vs Kyle Cook (Web Dev Simplified) { id: "r1-0", - date: "2026-02-01", + date: GAME_SCHEDULE["left-r1"], player1: jasonLengstorf, player2: kyleCook, }, // Game 1: Adam Wathan vs Julia Miocene { id: "r1-1", - date: "2026-02-01", + date: GAME_SCHEDULE["left-r1"], player1: adamWathan, player2: juliaMiocene, }, // Game 2: Chris Coyier vs Bree Hall { id: "r1-2", - date: "2026-02-02", + date: GAME_SCHEDULE["left-r1"], player1: chrisCoyier, player2: breeHall, }, // Game 3: Scott Tolinski vs Shaundai Person { id: "r1-3", - date: "2026-02-02", + date: GAME_SCHEDULE["left-r1"], player1: scottTolinski, player2: shaundaiPerson, }, @@ -250,28 +333,28 @@ export const bracket: Bracket = { // Game 4: Kevin Powell vs Amy Dutton { id: "r1-4", - date: "2026-02-01", + date: GAME_SCHEDULE["right-r1"], player1: kevinPowell, player2: amyDutton, }, // Game 5: Josh Comeau vs Cassidy Williams { id: "r1-5", - date: "2026-02-01", + date: GAME_SCHEDULE["right-r1"], player1: joshComeau, player2: cassidyWilliams, }, // Game 6: Wes Bos vs TBD { id: "r1-6", - date: "2026-02-02", + date: GAME_SCHEDULE["right-r1"], player1: wesBos, player2: benHong, }, // Game 7: Ania Kubow vs Adam Argyle { id: "r1-7", - date: "2026-02-02", + date: GAME_SCHEDULE["right-r1"], player1: aniaKubow, player2: adamArgyle, }, @@ -283,10 +366,10 @@ export const bracket: Bracket = { // Games 0-1: LEFT side | Games 2-3: RIGHT side quarters: [ - { id: "qf-0", date: "" }, - { id: "qf-1", date: "" }, - { id: "qf-2", date: "" }, - { id: "qf-3", date: "" }, + { id: "qf-0", date: GAME_SCHEDULE.qf }, + { id: "qf-1", date: GAME_SCHEDULE.qf }, + { id: "qf-2", date: GAME_SCHEDULE.qf }, + { id: "qf-3", date: GAME_SCHEDULE.qf }, ], // =========================================================================== @@ -295,15 +378,15 @@ export const bracket: Bracket = { // Game 0: LEFT side | Game 1: RIGHT side semis: [ - { id: "sf-0", date: "" }, - { id: "sf-1", date: "" }, + { id: "sf-0", date: GAME_SCHEDULE.sf }, + { id: "sf-1", date: GAME_SCHEDULE.sf }, ], // =========================================================================== // FINALS - 1 game, 2 players (CHAMPIONSHIP) // =========================================================================== - finals: [{ id: "final", date: "" }], + finals: [{ id: "final", date: GAME_SCHEDULE.final }], }; export const emptyBracket: Bracket = { @@ -374,3 +457,28 @@ export function splitForDisplay(games: T[]): { left: T[]; right: T[] } { right: games.slice(mid), }; } + +/** Get all completed game results from the bracket */ +export function getResultsFromBracket(): { + gameId: string; + winnerId: string; +}[] { + const results: { gameId: string; winnerId: string }[] = []; + for (const game of getAllGames()) { + if (game.winner) { + results.push({ gameId: game.id, winnerId: game.winner.id }); + } + } + return results; +} + +/** Mapping of which games feed into each slot (p1 from first, p2 from second) */ +export const FEEDER_GAMES: Record = { + "qf-0": ["r1-0", "r1-1"], + "qf-1": ["r1-2", "r1-3"], + "qf-2": ["r1-4", "r1-5"], + "qf-3": ["r1-6", "r1-7"], + "sf-0": ["qf-0", "qf-1"], + "sf-1": ["qf-2", "qf-3"], + final: ["sf-0", "sf-1"], +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index 30e12e4..d9d9f89 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,11 @@ import { relations, sql } from "drizzle-orm"; -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; export const user = sqliteTable("user", { id: text("id").primaryKey(), @@ -9,6 +15,7 @@ export const user = sqliteTable("user", { .default(false) .notNull(), image: text("image"), + username: text("username").unique(), createdAt: integer("created_at", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), @@ -87,9 +94,12 @@ export const verification = sqliteTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); -export const userRelations = relations(user, ({ many }) => ({ +export const userRelations = relations(user, ({ many, one }) => ({ sessions: many(session), accounts: many(account), + predictions: many(userPrediction), + bracketStatus: one(userBracketStatus), + score: one(userScore), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -105,3 +115,107 @@ export const accountRelations = relations(account, ({ one }) => ({ references: [user.id], }), })); + +// ============================================================================= +// USER PREDICTIONS +// ============================================================================= + +export const userPrediction = sqliteTable( + "user_prediction", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + gameId: text("game_id").notNull(), // e.g., "r1-0", "qf-1", "sf-0", "final" + predictedWinnerId: text("predicted_winner_id").notNull(), // player id + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + index("user_prediction_userId_idx").on(table.userId), + uniqueIndex("user_prediction_userId_gameId_unique").on( + table.userId, + table.gameId, + ), + ], +); + +export const userBracketStatus = sqliteTable( + "user_bracket_status", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + isLocked: integer("is_locked", { mode: "boolean" }) + .default(false) + .notNull(), + lockedAt: integer("locked_at", { mode: "timestamp_ms" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => [index("user_bracket_status_userId_idx").on(table.userId)], +); + +export const userPredictionRelations = relations(userPrediction, ({ one }) => ({ + user: one(user, { + fields: [userPrediction.userId], + references: [user.id], + }), +})); + +export const userBracketStatusRelations = relations( + userBracketStatus, + ({ one }) => ({ + user: one(user, { + fields: [userBracketStatus.userId], + references: [user.id], + }), + }), +); + +// ============================================================================= +// USER SCORES (LEADERBOARD) +// ============================================================================= + +export const userScore = sqliteTable( + "user_score", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + round1Score: integer("round1_score").default(0).notNull(), + round2Score: integer("round2_score").default(0).notNull(), + round3Score: integer("round3_score").default(0).notNull(), + round4Score: integer("round4_score").default(0).notNull(), + totalScore: integer("total_score").default(0).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + index("user_score_userId_idx").on(table.userId), + index("user_score_totalScore_idx").on(table.totalScore), + ], +); + +export const userScoreRelations = relations(userScore, ({ one }) => ({ + user: one(user, { + fields: [userScore.userId], + references: [user.id], + }), +})); diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..8665b77 --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; + +export type CountdownTime = { + days: number; + hours: number; + minutes: number; + seconds: number; + totalMs: number; +}; + +function getTimeRemaining(deadline: string): CountdownTime { + const total = new Date(deadline).getTime() - Date.now(); + if (total <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }; + } + return { + days: Math.floor(total / (1000 * 60 * 60 * 24)), + hours: Math.floor((total / (1000 * 60 * 60)) % 24), + minutes: Math.floor((total / (1000 * 60)) % 60), + seconds: Math.floor((total / 1000) % 60), + totalMs: total, + }; +} + +export function useCountdown(deadline: string | undefined): CountdownTime { + const [time, setTime] = useState(() => + deadline + ? getTimeRemaining(deadline) + : { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }, + ); + + useEffect(() => { + if (!deadline) return; + setTime(getTimeRemaining(deadline)); + const interval = setInterval(() => { + setTime(getTimeRemaining(deadline)); + }, 1000); + return () => clearInterval(interval); + }, [deadline]); + + return time; +} diff --git a/src/hooks/usePredictions.test.ts b/src/hooks/usePredictions.test.ts new file mode 100644 index 0000000..8f118dd --- /dev/null +++ b/src/hooks/usePredictions.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { getPickablePlayersForGame } from "./usePredictions"; + +describe("getPickablePlayersForGame", () => { + describe("round 1 games", () => { + it("returns fixed players for r1-0", () => { + const predictions = {}; + const [p1, p2] = getPickablePlayersForGame("r1-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("kyle-cook"); + }); + + it("returns fixed players regardless of predictions", () => { + const predictions = { "r1-0": "jason-lengstorf" }; + const [p1, p2] = getPickablePlayersForGame("r1-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("kyle-cook"); + }); + }); + + describe("quarterfinal games", () => { + it("returns undefined when source games have no predictions", () => { + const predictions = {}; + const [p1, p2] = getPickablePlayersForGame("qf-0", predictions); + + expect(p1).toBeUndefined(); + expect(p2).toBeUndefined(); + }); + + it("returns player from r1-0 winner as p1", () => { + const predictions = { "r1-0": "jason-lengstorf" }; + const [p1, p2] = getPickablePlayersForGame("qf-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBeUndefined(); + }); + + it("returns player from r1-1 winner as p2", () => { + const predictions = { "r1-1": "adam-wathan" }; + const [p1, p2] = getPickablePlayersForGame("qf-0", predictions); + + expect(p1).toBeUndefined(); + expect(p2).toBe("adam-wathan"); + }); + + it("returns both players when both source games have predictions", () => { + const predictions = { + "r1-0": "jason-lengstorf", + "r1-1": "adam-wathan", + }; + const [p1, p2] = getPickablePlayersForGame("qf-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("adam-wathan"); + }); + }); + + describe("semifinal games", () => { + it("returns winners from quarterfinals", () => { + const predictions = { + "r1-0": "jason-lengstorf", + "r1-1": "adam-wathan", + "r1-2": "chris-coyier", + "r1-3": "scott-tolinski", + "qf-0": "jason-lengstorf", + "qf-1": "chris-coyier", + }; + const [p1, p2] = getPickablePlayersForGame("sf-0", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("chris-coyier"); + }); + }); + + describe("final game", () => { + it("returns winners from semifinals", () => { + const predictions = { + "sf-0": "jason-lengstorf", + "sf-1": "kevin-powell", + }; + const [p1, p2] = getPickablePlayersForGame("final", predictions); + + expect(p1).toBe("jason-lengstorf"); + expect(p2).toBe("kevin-powell"); + }); + }); + + describe("invalid game IDs", () => { + it("returns undefined for unknown game ID", () => { + const predictions = {}; + const [p1, p2] = getPickablePlayersForGame("invalid-game", predictions); + + expect(p1).toBeUndefined(); + expect(p2).toBeUndefined(); + }); + }); +}); diff --git a/src/hooks/usePredictions.ts b/src/hooks/usePredictions.ts new file mode 100644 index 0000000..90875c4 --- /dev/null +++ b/src/hooks/usePredictions.ts @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BRACKET_DEADLINE, bracket, TOTAL_GAMES } from "@/data/players"; +import { + useLockBracketMutation, + usePredictionsQuery, + useSavePredictionsMutation, +} from "./usePredictionsQuery"; + +export type Prediction = { + gameId: string; + predictedWinnerId: string; +}; + +export type PredictionsState = { + predictions: Record; // gameId -> playerId + isLocked: boolean; + lockedAt: string | null; + isLoading: boolean; + isSaving: boolean; + error: string | null; + pickCount: number; + deadline: string; + isDeadlinePassed: boolean; +}; + +// Maps game IDs to the game they feed into and which player slot +// e.g., r1-0 winner goes to qf-0 as player1 +const GAME_ADVANCEMENT_MAP: Record< + string, + { nextGameId: string; slot: "player1" | "player2" } +> = { + // Round 1 -> Quarterfinals + "r1-0": { nextGameId: "qf-0", slot: "player1" }, + "r1-1": { nextGameId: "qf-0", slot: "player2" }, + "r1-2": { nextGameId: "qf-1", slot: "player1" }, + "r1-3": { nextGameId: "qf-1", slot: "player2" }, + "r1-4": { nextGameId: "qf-2", slot: "player1" }, + "r1-5": { nextGameId: "qf-2", slot: "player2" }, + "r1-6": { nextGameId: "qf-3", slot: "player1" }, + "r1-7": { nextGameId: "qf-3", slot: "player2" }, + // Quarterfinals -> Semifinals + "qf-0": { nextGameId: "sf-0", slot: "player1" }, + "qf-1": { nextGameId: "sf-0", slot: "player2" }, + "qf-2": { nextGameId: "sf-1", slot: "player1" }, + "qf-3": { nextGameId: "sf-1", slot: "player2" }, + // Semifinals -> Finals + "sf-0": { nextGameId: "final", slot: "player1" }, + "sf-1": { nextGameId: "final", slot: "player2" }, +}; + +// Reverse map: for a given game, what are the source games for each player slot? +const GAME_SOURCE_MAP: Record< + string, + { player1Source?: string; player2Source?: string } +> = { + "qf-0": { player1Source: "r1-0", player2Source: "r1-1" }, + "qf-1": { player1Source: "r1-2", player2Source: "r1-3" }, + "qf-2": { player1Source: "r1-4", player2Source: "r1-5" }, + "qf-3": { player1Source: "r1-6", player2Source: "r1-7" }, + "sf-0": { player1Source: "qf-0", player2Source: "qf-1" }, + "sf-1": { player1Source: "qf-2", player2Source: "qf-3" }, + final: { player1Source: "sf-0", player2Source: "sf-1" }, +}; + +// Get the two players who can be picked for a given game +// Returns [player1Id, player2Id] where each slot preserves its position +// (undefined if the source game has no prediction yet) +export function getPickablePlayersForGame( + gameId: string, + predictions: Record, +): [string | undefined, string | undefined] { + // Round 1 games have fixed players based on the bracket structure + if (gameId.startsWith("r1-")) { + const gameIndex = Number.parseInt(gameId.split("-")[1], 10); + const game = bracket.round1[gameIndex]; + return [game?.player1?.id, game?.player2?.id]; + } + + // Later rounds: get winners from source games + // Preserve slot positions - player1 always comes from player1Source, etc. + const sources = GAME_SOURCE_MAP[gameId]; + if (!sources) return [undefined, undefined]; + + const player1 = sources.player1Source + ? predictions[sources.player1Source] + : undefined; + const player2 = sources.player2Source + ? predictions[sources.player2Source] + : undefined; + + return [player1, player2]; +} + +// Get games that need to be cleared when changing a pick +function getGamesToClear( + gameId: string, + oldPlayerId: string, + currentPredictions: Record, +): string[] { + const gamesToClear: string[] = []; + const advancement = GAME_ADVANCEMENT_MAP[gameId]; + + if (!advancement || !oldPlayerId) return gamesToClear; + + const { nextGameId } = advancement; + const currentPick = currentPredictions[nextGameId]; + + // If the old player was picked in the next game, we need to clear it + if (currentPick === oldPlayerId) { + gamesToClear.push(nextGameId); + // Recursively clear further games + gamesToClear.push( + ...getGamesToClear(nextGameId, oldPlayerId, currentPredictions), + ); + } + + return gamesToClear; +} + +export type UsePredictionsReturn = ReturnType; + +export function usePredictions(isAuthenticated: boolean, userId?: string) { + // TanStack Query for fetching predictions + const { + data: queryData, + isLoading: queryIsLoading, + error: queryError, + } = usePredictionsQuery(isAuthenticated ? userId : undefined); + + // Mutations + const saveMutation = useSavePredictionsMutation(userId); + const lockMutation = useLockBracketMutation(userId); + + // Local state for optimistic updates while picking + const [localPredictions, setLocalPredictions] = useState | null>(null); + + // Track if local state differs from server state + const hasChanges = useMemo(() => { + if (!localPredictions || !queryData) return false; + const serverKeys = Object.keys(queryData.predictions); + const localKeys = Object.keys(localPredictions); + if (serverKeys.length !== localKeys.length) return true; + return localKeys.some( + (key) => localPredictions[key] !== queryData.predictions[key], + ); + }, [localPredictions, queryData]); + + // Sync local state when query data changes (initial load or after mutation) + useEffect(() => { + if (queryData && !localPredictions) { + setLocalPredictions(queryData.predictions); + } + }, [queryData, localPredictions]); + + // Reset local state when user logs out + useEffect(() => { + if (!isAuthenticated) { + setLocalPredictions(null); + } + }, [isAuthenticated]); + + // The actual predictions to use (local if available, otherwise from query) + const predictions = localPredictions ?? queryData?.predictions ?? {}; + const isLocked = queryData?.isLocked ?? false; + const lockedAt = queryData?.lockedAt ?? null; + + // Calculate deadline status + const isDeadlinePassed = new Date() > new Date(BRACKET_DEADLINE); + + // Determine loading state + const isLoading = isAuthenticated && queryIsLoading; + + // Determine saving state + const isSaving = saveMutation.isPending || lockMutation.isPending; + + // Determine error state + const error = + queryError?.message || + saveMutation.error?.message || + lockMutation.error?.message || + null; + + // Set a prediction with cascading logic + const setPrediction = useCallback( + (gameId: string, playerId: string) => { + if (isLocked || isDeadlinePassed) return; + + setLocalPredictions((prev) => { + const current = prev ?? queryData?.predictions ?? {}; + const newPredictions = { ...current }; + const oldPlayerId = current[gameId]; + + // If changing pick, clear any cascaded picks of the old player + if (oldPlayerId && oldPlayerId !== playerId) { + const gamesToClear = getGamesToClear(gameId, oldPlayerId, current); + for (const clearGameId of gamesToClear) { + delete newPredictions[clearGameId]; + } + } + + // Set the new prediction + newPredictions[gameId] = playerId; + + return newPredictions; + }); + }, + [isLocked, isDeadlinePassed, queryData?.predictions], + ); + + // Save predictions to the server + const savePredictions = useCallback(async () => { + if (isLocked || isDeadlinePassed || !isAuthenticated || !localPredictions) + return; + + await saveMutation.mutateAsync(localPredictions); + }, [ + localPredictions, + isLocked, + isDeadlinePassed, + isAuthenticated, + saveMutation, + ]); + + // Reset all predictions + const resetPredictions = useCallback(() => { + if (isLocked || isDeadlinePassed) return; + + setLocalPredictions({}); + }, [isLocked, isDeadlinePassed]); + + // Lock the bracket + const lockBracket = useCallback(async () => { + if (isLocked || isDeadlinePassed || !isAuthenticated) return; + + // First save any unsaved predictions + if (hasChanges && localPredictions) { + await saveMutation.mutateAsync(localPredictions); + } + + await lockMutation.mutateAsync(); + }, [ + isLocked, + isDeadlinePassed, + isAuthenticated, + hasChanges, + localPredictions, + saveMutation, + lockMutation, + ]); + + const pickCount = Object.keys(predictions).length; + + return { + predictions, + isLocked, + lockedAt, + isLoading, + isSaving, + error, + pickCount, + deadline: BRACKET_DEADLINE, + isDeadlinePassed, + hasChanges, + totalGames: TOTAL_GAMES, + setPrediction, + savePredictions, + lockBracket, + resetPredictions, + getPickablePlayersForGame: (gameId: string) => + getPickablePlayersForGame(gameId, predictions), + }; +} diff --git a/src/hooks/usePredictionsQuery.ts b/src/hooks/usePredictionsQuery.ts new file mode 100644 index 0000000..2ffbfb1 --- /dev/null +++ b/src/hooks/usePredictionsQuery.ts @@ -0,0 +1,118 @@ +import { + type QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +export type PredictionsData = { + predictions: Record; + isLocked: boolean; + lockedAt: string | null; +}; + +async function fetchPredictions(): Promise { + const response = await fetch("/api/predictions/"); + if (!response.ok) { + throw new Error("Failed to fetch predictions"); + } + const data = await response.json(); + + // Convert array to record + const predictions: Record = {}; + for (const pred of data.predictions) { + predictions[pred.gameId] = pred.predictedWinnerId; + } + + return { + predictions, + isLocked: data.isLocked, + lockedAt: data.lockedAt, + }; +} + +async function savePredictionsApi( + predictions: Record, +): Promise { + const predictionsArray = Object.entries(predictions).map( + ([gameId, predictedWinnerId]) => ({ + gameId, + predictedWinnerId, + }), + ); + + const response = await fetch("/api/predictions/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ predictions: predictionsArray }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to save predictions"); + } +} + +async function lockBracketApi(): Promise<{ lockedAt: string }> { + const response = await fetch("/api/predictions/lock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to lock bracket"); + } + + return response.json(); +} + +export function predictionsQueryKey(userId: string | undefined) { + return ["predictions", userId] as const; +} + +export function usePredictionsQuery(userId: string | undefined) { + return useQuery({ + queryKey: predictionsQueryKey(userId), + queryFn: fetchPredictions, + enabled: !!userId, + staleTime: (query) => { + // If locked, cache for 1 hour (only admin unlock invalidates) + // If unlocked, cache for 30 seconds + return query.state.data?.isLocked ? 1000 * 60 * 60 : 1000 * 30; + }, + }); +} + +export function useSavePredictionsMutation(userId: string | undefined) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: savePredictionsApi, + onSuccess: () => { + if (userId) { + queryClient.invalidateQueries({ + queryKey: predictionsQueryKey(userId), + }); + } + }, + }); +} + +export function useLockBracketMutation(userId: string | undefined) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: lockBracketApi, + onSuccess: () => { + if (userId) { + queryClient.invalidateQueries({ + queryKey: predictionsQueryKey(userId), + }); + } + }, + }); +} + +// Helper to invalidate all predictions (used by admin unlock) +export function invalidateAllPredictions(queryClient: QueryClient) { + queryClient.invalidateQueries({ queryKey: ["predictions"] }); +} diff --git a/src/lib/admin.ts b/src/lib/admin.ts new file mode 100644 index 0000000..36f3f0c --- /dev/null +++ b/src/lib/admin.ts @@ -0,0 +1,37 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "@/db"; +import * as schema from "@/db/schema"; + +// GitHub User IDs (immutable, verified via `gh api users/{username}`) +// These NEVER change even if the user changes their GitHub username +export const ADMIN_GITHUB_IDS = [ + "176013", // wesbos + "669383", // stolinski + "14241866", // w3cj + "3760543", // sergical +] as const; + +/** + * SERVER-SIDE ONLY: Check if a user is an admin by querying their GitHub account ID + * This is the ONLY secure way to check admin status - never trust client-supplied data + */ +export async function isAdminUser( + db: Database, + userId: string, +): Promise { + const githubAccount = await db + .select({ accountId: schema.account.accountId }) + .from(schema.account) + .where( + and( + eq(schema.account.userId, userId), + eq(schema.account.providerId, "github"), + ), + ) + .get(); + + if (!githubAccount) return false; + return ADMIN_GITHUB_IDS.includes( + githubAccount.accountId as (typeof ADMIN_GITHUB_IDS)[number], + ); +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 384588e..4cd345d 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -3,3 +3,12 @@ import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient(); export const { signIn, signOut, useSession } = authClient; + +// Client-side admin check - FOR UI DISPLAY ONLY, NOT FOR SECURITY +// Actual authorization must always be done server-side via isAdminUser() +export const ADMIN_GITHUB_IDS = [ + "176013", // wesbos + "669383", // stolinski + "14241866", // w3cj + "3760543", // sergical +] as const; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7b25cd4..c5f14ba 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,6 +2,7 @@ import type { D1Database } from "@cloudflare/workers-types"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; +import { eq } from "drizzle-orm"; import { createDb } from "@/db"; import * as schema from "@/db/schema"; @@ -13,10 +14,55 @@ export function createAuth(d1: D1Database) { provider: "sqlite", schema, }), + trustedOrigins: [ + "http://localhost:3000", + process.env.BETTER_AUTH_URL, + ].filter(Boolean) as string[], socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID || "", clientSecret: process.env.GITHUB_CLIENT_SECRET || "", + mapProfileToUser: (profile) => ({ + username: profile.login, + }), + }, + }, + user: { + additionalFields: { + username: { + type: "string", + required: false, + }, + }, + }, + databaseHooks: { + user: { + create: { + after: async (user) => { + // Backfill username from GitHub account if not set + if (!user.username) { + const accounts = await db + .select() + .from(schema.account) + .where(eq(schema.account.userId, user.id)); + const githubAccount = accounts.find( + (a) => a.providerId === "github", + ); + if (githubAccount?.accountId) { + // accountId is the GitHub user ID, we'll fetch the username via API + // For now, use the name as fallback + const username = + user.name?.toLowerCase().replace(/\s+/g, "") || null; + if (username) { + await db + .update(schema.user) + .set({ username }) + .where(eq(schema.user.id, user.id)); + } + } + } + }, + }, }, }, plugins: [tanstackStartCookies()], diff --git a/src/lib/middleware/admin.ts b/src/lib/middleware/admin.ts new file mode 100644 index 0000000..e3290b9 --- /dev/null +++ b/src/lib/middleware/admin.ts @@ -0,0 +1,48 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { createDb } from "@/db"; +import { isAdminUser } from "@/lib/admin"; +import { createAuth } from "@/lib/auth"; + +type AdminResult = + | { success: true; user: { id: string; name: string; email: string } } + | { success: false; response: Response }; + +/** + * Validates request authentication and admin status. + * Use in admin API handlers to reduce auth boilerplate. + */ +export async function requireAdmin( + request: Request, + d1: D1Database, +): Promise { + const auth = createAuth(d1); + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return { + success: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }; + } + + const db = createDb(d1); + const isAdmin = await isAdminUser(db, session.user.id); + + if (!isAdmin) { + return { + success: false, + response: new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + }; + } + + return { + success: true, + user: session.user, + }; +} diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts new file mode 100644 index 0000000..6f250b5 --- /dev/null +++ b/src/lib/middleware/auth.ts @@ -0,0 +1,33 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { createAuth } from "@/lib/auth"; + +type AuthResult = + | { success: true; user: { id: string; name: string; email: string } } + | { success: false; response: Response }; + +/** + * Validates request authentication and returns user or error response. + * Use in API handlers to reduce auth boilerplate. + */ +export async function requireAuth( + request: Request, + db: D1Database, +): Promise { + const auth = createAuth(db); + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return { + success: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }; + } + + return { + success: true, + user: session.user, + }; +} diff --git a/src/lib/schemas/prediction.ts b/src/lib/schemas/prediction.ts new file mode 100644 index 0000000..dc09362 --- /dev/null +++ b/src/lib/schemas/prediction.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { ALL_GAME_IDS, players } from "@/data/players"; + +// Valid player IDs from tournament roster +const VALID_PLAYER_IDS = players.map((p) => p.id); + +export const gameIdSchema = z.enum(ALL_GAME_IDS); + +export const playerIdSchema = z + .string() + .refine((id) => VALID_PLAYER_IDS.includes(id), { + message: "Invalid player ID", + }); + +export const predictionSchema = z.object({ + gameId: gameIdSchema, + predictedWinnerId: playerIdSchema, +}); + +export const predictionsArraySchema = z.array(predictionSchema); + +export type Prediction = z.infer; +export type PredictionsArray = z.infer; diff --git a/src/lib/scoring.test.ts b/src/lib/scoring.test.ts new file mode 100644 index 0000000..96124e4 --- /dev/null +++ b/src/lib/scoring.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { calculateScoresForUser } from "./scoring"; + +describe("calculateScoresForUser", () => { + it("returns zero scores when no predictions match", () => { + const predictions = [{ gameId: "r1-0", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "r1-0", winnerId: "player-b" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores).toEqual({ + round1Score: 0, + round2Score: 0, + round3Score: 0, + round4Score: 0, + totalScore: 0, + }); + }); + + it("returns zero scores when no results exist", () => { + const predictions = [{ gameId: "r1-0", predictedWinnerId: "player-a" }]; + const results: Array<{ gameId: string; winnerId: string }> = []; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores).toEqual({ + round1Score: 0, + round2Score: 0, + round3Score: 0, + round4Score: 0, + totalScore: 0, + }); + }); + + it("awards 10 points for correct round 1 pick", () => { + const predictions = [{ gameId: "r1-0", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "r1-0", winnerId: "player-a" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round1Score).toBe(10); + expect(scores.totalScore).toBe(10); + }); + + it("awards 20 points for correct quarterfinal pick", () => { + const predictions = [{ gameId: "qf-0", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "qf-0", winnerId: "player-a" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round2Score).toBe(20); + expect(scores.totalScore).toBe(20); + }); + + it("awards 40 points for correct semifinal pick", () => { + const predictions = [{ gameId: "sf-0", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "sf-0", winnerId: "player-a" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round3Score).toBe(40); + expect(scores.totalScore).toBe(40); + }); + + it("awards 80 points for correct final pick", () => { + const predictions = [{ gameId: "final", predictedWinnerId: "player-a" }]; + const results = [{ gameId: "final", winnerId: "player-a" }]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round4Score).toBe(80); + expect(scores.totalScore).toBe(80); + }); + + it("calculates total score across all rounds", () => { + const predictions = [ + { gameId: "r1-0", predictedWinnerId: "player-a" }, + { gameId: "r1-1", predictedWinnerId: "player-b" }, + { gameId: "qf-0", predictedWinnerId: "player-a" }, + { gameId: "sf-0", predictedWinnerId: "player-a" }, + { gameId: "final", predictedWinnerId: "player-a" }, + ]; + const results = [ + { gameId: "r1-0", winnerId: "player-a" }, + { gameId: "r1-1", winnerId: "player-b" }, + { gameId: "qf-0", winnerId: "player-a" }, + { gameId: "sf-0", winnerId: "player-a" }, + { gameId: "final", winnerId: "player-a" }, + ]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round1Score).toBe(20); // 2 correct R1 picks + expect(scores.round2Score).toBe(20); // 1 correct QF pick + expect(scores.round3Score).toBe(40); // 1 correct SF pick + expect(scores.round4Score).toBe(80); // 1 correct final pick + expect(scores.totalScore).toBe(160); + }); + + it("ignores predictions for games without results", () => { + const predictions = [ + { gameId: "r1-0", predictedWinnerId: "player-a" }, + { gameId: "qf-0", predictedWinnerId: "player-a" }, + ]; + const results = [ + { gameId: "r1-0", winnerId: "player-a" }, + // qf-0 has no result yet + ]; + + const scores = calculateScoresForUser(predictions, results); + + expect(scores.round1Score).toBe(10); + expect(scores.round2Score).toBe(0); + expect(scores.totalScore).toBe(10); + }); +}); diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts new file mode 100644 index 0000000..66128d7 --- /dev/null +++ b/src/lib/scoring.ts @@ -0,0 +1,151 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { eq, inArray } from "drizzle-orm"; +import { + FINAL_GAME_IDS, + getResultsFromBracket, + QUARTER_GAME_IDS, + ROUND_1_GAME_IDS, + SEMI_GAME_IDS, +} from "@/data/players"; +import { createDb } from "@/db"; +import * as schema from "@/db/schema"; + +// Points per correct pick in each round +const ROUND_1_POINTS = 10; +const QUARTER_POINTS = 20; +const SEMI_POINTS = 40; +const FINAL_POINTS = 80; + +type RoundScores = { + round1Score: number; + round2Score: number; + round3Score: number; + round4Score: number; + totalScore: number; +}; + +export function calculateScoresForUser( + predictions: Array<{ gameId: string; predictedWinnerId: string }>, + results: Array<{ gameId: string; winnerId: string }>, +): RoundScores { + const resultsMap = new Map(results.map((r) => [r.gameId, r.winnerId])); + + let round1Score = 0; + let round2Score = 0; + let round3Score = 0; + let round4Score = 0; + + for (const prediction of predictions) { + const actualWinner = resultsMap.get(prediction.gameId); + if (!actualWinner) continue; // Game not played yet + + const isCorrect = prediction.predictedWinnerId === actualWinner; + if (!isCorrect) continue; + + if (ROUND_1_GAME_IDS.includes(prediction.gameId)) { + round1Score += ROUND_1_POINTS; + } else if (QUARTER_GAME_IDS.includes(prediction.gameId)) { + round2Score += QUARTER_POINTS; + } else if (SEMI_GAME_IDS.includes(prediction.gameId)) { + round3Score += SEMI_POINTS; + } else if (FINAL_GAME_IDS.includes(prediction.gameId)) { + round4Score += FINAL_POINTS; + } + } + + return { + round1Score, + round2Score, + round3Score, + round4Score, + totalScore: round1Score + round2Score + round3Score + round4Score, + }; +} + +export async function recalculateAllUserScores(database: D1Database) { + const db = createDb(database); + + // Get results from players.ts (single source of truth) + const bracketResults = getResultsFromBracket(); + const results = bracketResults.map((r) => ({ + gameId: r.gameId, + winnerId: r.winnerId, + })); + + if (results.length === 0) { + return { updated: 0 }; + } + + // Get all users with locked brackets + const lockedUsers = await db + .select({ userId: schema.userBracketStatus.userId }) + .from(schema.userBracketStatus) + .where(eq(schema.userBracketStatus.isLocked, true)); + + if (lockedUsers.length === 0) { + return { updated: 0 }; + } + + const userIds = lockedUsers.map((u) => u.userId); + + // Get all predictions for locked users + const allPredictions = await db + .select({ + userId: schema.userPrediction.userId, + gameId: schema.userPrediction.gameId, + predictedWinnerId: schema.userPrediction.predictedWinnerId, + }) + .from(schema.userPrediction) + .where(inArray(schema.userPrediction.userId, userIds)); + + // Group predictions by user + const predictionsByUser = new Map< + string, + Array<{ gameId: string; predictedWinnerId: string }> + >(); + for (const p of allPredictions) { + const existing = predictionsByUser.get(p.userId) || []; + existing.push({ gameId: p.gameId, predictedWinnerId: p.predictedWinnerId }); + predictionsByUser.set(p.userId, existing); + } + + // Calculate and upsert scores for each user + let updated = 0; + for (const userId of userIds) { + const userPredictions = predictionsByUser.get(userId) || []; + const scores = calculateScoresForUser(userPredictions, results); + + // Check if score record exists + const existing = await db + .select() + .from(schema.userScore) + .where(eq(schema.userScore.userId, userId)) + .limit(1); + + if (existing.length > 0) { + await db + .update(schema.userScore) + .set({ + round1Score: scores.round1Score, + round2Score: scores.round2Score, + round3Score: scores.round3Score, + round4Score: scores.round4Score, + totalScore: scores.totalScore, + }) + .where(eq(schema.userScore.userId, userId)); + } else { + await db.insert(schema.userScore).values({ + id: crypto.randomUUID(), + userId, + round1Score: scores.round1Score, + round2Score: scores.round2Score, + round3Score: scores.round3Score, + round4Score: scores.round4Score, + totalScore: scores.totalScore, + }); + } + updated++; + } + + return { updated }; +} diff --git a/src/lib/simulation.ts b/src/lib/simulation.ts new file mode 100644 index 0000000..3dd2400 --- /dev/null +++ b/src/lib/simulation.ts @@ -0,0 +1,214 @@ +import { + bracket, + FINAL_GAME_IDS, + type Player, + players, + QUARTER_GAME_IDS, + ROUND_1_GAME_IDS, + SEMI_GAME_IDS, +} from "@/data/players"; + +// ============================================================================= +// SIMULATION STAGES +// ============================================================================= + +export type SimulationStage = + | "r1-left" + | "r1-right" + | "quarterfinals" + | "semifinals" + | "finals"; + +export const SIMULATION_STAGES: SimulationStage[] = [ + "r1-left", + "r1-right", + "quarterfinals", + "semifinals", + "finals", +]; + +export const STAGE_CONFIG: Record< + SimulationStage, + { label: string; gameIds: readonly string[] } +> = { + "r1-left": { + label: "Round 1 - Left", + gameIds: ROUND_1_GAME_IDS.slice(0, 4), + }, + "r1-right": { + label: "Round 1 - Right", + gameIds: ROUND_1_GAME_IDS.slice(4), + }, + quarterfinals: { + label: "Quarterfinals", + gameIds: QUARTER_GAME_IDS, + }, + semifinals: { + label: "Semifinals", + gameIds: SEMI_GAME_IDS, + }, + finals: { + label: "Finals", + gameIds: FINAL_GAME_IDS, + }, +}; + +// ============================================================================= +// ACTIVE ROUND CALCULATION +// ============================================================================= + +export type ActiveRound = + | "round1" + | "quarters" + | "semis" + | "finals" + | "complete"; + +export function getActiveRound(results: Record): ActiveRound { + const r1LeftComplete = ROUND_1_GAME_IDS.slice(0, 4).every((g) => results[g]); + const r1RightComplete = ROUND_1_GAME_IDS.slice(4).every((g) => results[g]); + const qfComplete = QUARTER_GAME_IDS.every((g) => results[g]); + const sfComplete = SEMI_GAME_IDS.every((g) => results[g]); + const finalsComplete = FINAL_GAME_IDS.every((g) => results[g]); + + if (!r1LeftComplete || !r1RightComplete) return "round1"; + if (!qfComplete) return "quarters"; + if (!sfComplete) return "semis"; + if (!finalsComplete) return "finals"; + return "complete"; +} + +export function getRoundForGame( + gameId: string, +): "round1" | "quarters" | "semis" | "finals" { + if (gameId.startsWith("r1-")) return "round1"; + if (gameId.startsWith("qf-")) return "quarters"; + if (gameId.startsWith("sf-")) return "semis"; + return "finals"; +} + +export function isGameInActiveRound( + gameId: string, + activeRound: ActiveRound, +): boolean { + if (activeRound === "complete") return false; + return getRoundForGame(gameId) === activeRound; +} + +// ============================================================================= +// PLAYER ADVANCEMENT LOGIC +// ============================================================================= + +export function getPlayersForGame( + gameId: string, + results: Record, +): [string | undefined, string | undefined] { + // Round 1 games have fixed players from bracket data + if (gameId.startsWith("r1-")) { + const game = bracket.round1.find((g) => g.id === gameId); + return [game?.player1?.id, game?.player2?.id]; + } + + // Quarterfinals: winners from R1 + if (gameId === "qf-0") { + return [results["r1-0"], results["r1-1"]]; + } + if (gameId === "qf-1") { + return [results["r1-2"], results["r1-3"]]; + } + if (gameId === "qf-2") { + return [results["r1-4"], results["r1-5"]]; + } + if (gameId === "qf-3") { + return [results["r1-6"], results["r1-7"]]; + } + + // Semifinals: winners from QF + if (gameId === "sf-0") { + return [results["qf-0"], results["qf-1"]]; + } + if (gameId === "sf-1") { + return [results["qf-2"], results["qf-3"]]; + } + + // Finals: winners from SF + if (gameId === "final") { + return [results["sf-0"], results["sf-1"]]; + } + + return [undefined, undefined]; +} + +export function getPlayerById(playerId: string): Player | undefined { + return players.find((p) => p.id === playerId); +} + +// ============================================================================= +// RANDOM SIMULATION +// ============================================================================= + +export function simulateStage( + stage: SimulationStage, + currentResults: Record, +): Record { + const newResults = { ...currentResults }; + const gameIds = STAGE_CONFIG[stage].gameIds; + + for (const gameId of gameIds) { + const [p1, p2] = getPlayersForGame(gameId, newResults); + if (p1 && p2) { + // Always pick player 1 (top player) for deterministic simulation + newResults[gameId] = p1; + } + } + + return newResults; +} + +export function getCurrentStage( + results: Record, +): SimulationStage | null { + // Find the first incomplete stage + for (const stage of SIMULATION_STAGES) { + const gameIds = STAGE_CONFIG[stage].gameIds; + const allComplete = gameIds.every((id) => results[id]); + if (!allComplete) { + return stage; + } + } + return null; +} + +export function getPreviousStage( + stage: SimulationStage, +): SimulationStage | null { + const idx = SIMULATION_STAGES.indexOf(stage); + return idx > 0 ? SIMULATION_STAGES[idx - 1] : null; +} + +export function getNextStage(stage: SimulationStage): SimulationStage | null { + const idx = SIMULATION_STAGES.indexOf(stage); + return idx < SIMULATION_STAGES.length - 1 ? SIMULATION_STAGES[idx + 1] : null; +} + +// Get all game IDs up to and including a stage +export function getGameIdsUpToStage(stage: SimulationStage): string[] { + const gameIds: string[] = []; + for (const s of SIMULATION_STAGES) { + gameIds.push(...STAGE_CONFIG[s].gameIds); + if (s === stage) break; + } + return gameIds; +} + +// Build complete results up to and including a stage (simulating all stages) +export function buildResultsUpToStage( + stage: SimulationStage, +): Record { + let results: Record = {}; + for (const s of SIMULATION_STAGES) { + results = simulateStage(s, results); + if (s === stage) break; + } + return results; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 363d6fd..1372a02 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,48 +9,225 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as TestRouteImport } from './routes/test' +import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' +import { Route as BracketUsernameRouteImport } from './routes/bracket/$username' +import { Route as ApiPredictionsIndexRouteImport } from './routes/api/predictions/index' +import { Route as ApiPredictionsLockRouteImport } from './routes/api/predictions/lock' +import { Route as ApiOgUsernameRouteImport } from './routes/api/og.$username' +import { Route as ApiLeaderboardCalculateRouteImport } from './routes/api/leaderboard/calculate' +import { Route as ApiBracketUsernameRouteImport } from './routes/api/bracket/$username' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +import { Route as ApiAdminUsersRouteImport } from './routes/api/admin/users' +import { Route as ApiAdminCheckRouteImport } from './routes/api/admin/check' +import { Route as ApiAdminBracketsUnlockRouteImport } from './routes/api/admin/brackets/unlock' +import { Route as ApiAdminBracketsLockRouteImport } from './routes/api/admin/brackets/lock' +const TestRoute = TestRouteImport.update({ + id: '/test', + path: '/test', + getParentRoute: () => rootRouteImport, +} as any) +const AdminRoute = AdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const BracketUsernameRoute = BracketUsernameRouteImport.update({ + id: '/bracket/$username', + path: '/bracket/$username', + getParentRoute: () => rootRouteImport, +} as any) +const ApiPredictionsIndexRoute = ApiPredictionsIndexRouteImport.update({ + id: '/api/predictions/', + path: '/api/predictions/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiPredictionsLockRoute = ApiPredictionsLockRouteImport.update({ + id: '/api/predictions/lock', + path: '/api/predictions/lock', + getParentRoute: () => rootRouteImport, +} as any) +const ApiOgUsernameRoute = ApiOgUsernameRouteImport.update({ + id: '/api/og/$username', + path: '/api/og/$username', + getParentRoute: () => rootRouteImport, +} as any) +const ApiLeaderboardCalculateRoute = ApiLeaderboardCalculateRouteImport.update({ + id: '/api/leaderboard/calculate', + path: '/api/leaderboard/calculate', + getParentRoute: () => rootRouteImport, +} as any) +const ApiBracketUsernameRoute = ApiBracketUsernameRouteImport.update({ + id: '/api/bracket/$username', + path: '/api/bracket/$username', + getParentRoute: () => rootRouteImport, +} as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) +const ApiAdminUsersRoute = ApiAdminUsersRouteImport.update({ + id: '/api/admin/users', + path: '/api/admin/users', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAdminCheckRoute = ApiAdminCheckRouteImport.update({ + id: '/api/admin/check', + path: '/api/admin/check', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAdminBracketsUnlockRoute = ApiAdminBracketsUnlockRouteImport.update({ + id: '/api/admin/brackets/unlock', + path: '/api/admin/brackets/unlock', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAdminBracketsLockRoute = ApiAdminBracketsLockRouteImport.update({ + id: '/api/admin/brackets/lock', + path: '/api/admin/brackets/lock', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/test': typeof TestRoute + '/bracket/$username': typeof BracketUsernameRoute + '/api/admin/check': typeof ApiAdminCheckRoute + '/api/admin/users': typeof ApiAdminUsersRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/bracket/$username': typeof ApiBracketUsernameRoute + '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/og/$username': typeof ApiOgUsernameRoute + '/api/predictions/lock': typeof ApiPredictionsLockRoute + '/api/predictions/': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/lock': typeof ApiAdminBracketsLockRoute + '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/test': typeof TestRoute + '/bracket/$username': typeof BracketUsernameRoute + '/api/admin/check': typeof ApiAdminCheckRoute + '/api/admin/users': typeof ApiAdminUsersRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/bracket/$username': typeof ApiBracketUsernameRoute + '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/og/$username': typeof ApiOgUsernameRoute + '/api/predictions/lock': typeof ApiPredictionsLockRoute + '/api/predictions': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/lock': typeof ApiAdminBracketsLockRoute + '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/admin': typeof AdminRoute + '/test': typeof TestRoute + '/bracket/$username': typeof BracketUsernameRoute + '/api/admin/check': typeof ApiAdminCheckRoute + '/api/admin/users': typeof ApiAdminUsersRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/bracket/$username': typeof ApiBracketUsernameRoute + '/api/leaderboard/calculate': typeof ApiLeaderboardCalculateRoute + '/api/og/$username': typeof ApiOgUsernameRoute + '/api/predictions/lock': typeof ApiPredictionsLockRoute + '/api/predictions/': typeof ApiPredictionsIndexRoute + '/api/admin/brackets/lock': typeof ApiAdminBracketsLockRoute + '/api/admin/brackets/unlock': typeof ApiAdminBracketsUnlockRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/api/auth/$' + fullPaths: + | '/' + | '/admin' + | '/test' + | '/bracket/$username' + | '/api/admin/check' + | '/api/admin/users' + | '/api/auth/$' + | '/api/bracket/$username' + | '/api/leaderboard/calculate' + | '/api/og/$username' + | '/api/predictions/lock' + | '/api/predictions/' + | '/api/admin/brackets/lock' + | '/api/admin/brackets/unlock' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api/auth/$' - id: '__root__' | '/' | '/api/auth/$' + to: + | '/' + | '/admin' + | '/test' + | '/bracket/$username' + | '/api/admin/check' + | '/api/admin/users' + | '/api/auth/$' + | '/api/bracket/$username' + | '/api/leaderboard/calculate' + | '/api/og/$username' + | '/api/predictions/lock' + | '/api/predictions' + | '/api/admin/brackets/lock' + | '/api/admin/brackets/unlock' + id: + | '__root__' + | '/' + | '/admin' + | '/test' + | '/bracket/$username' + | '/api/admin/check' + | '/api/admin/users' + | '/api/auth/$' + | '/api/bracket/$username' + | '/api/leaderboard/calculate' + | '/api/og/$username' + | '/api/predictions/lock' + | '/api/predictions/' + | '/api/admin/brackets/lock' + | '/api/admin/brackets/unlock' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AdminRoute: typeof AdminRoute + TestRoute: typeof TestRoute + BracketUsernameRoute: typeof BracketUsernameRoute + ApiAdminCheckRoute: typeof ApiAdminCheckRoute + ApiAdminUsersRoute: typeof ApiAdminUsersRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute + ApiBracketUsernameRoute: typeof ApiBracketUsernameRoute + ApiLeaderboardCalculateRoute: typeof ApiLeaderboardCalculateRoute + ApiOgUsernameRoute: typeof ApiOgUsernameRoute + ApiPredictionsLockRoute: typeof ApiPredictionsLockRoute + ApiPredictionsIndexRoute: typeof ApiPredictionsIndexRoute + ApiAdminBracketsLockRoute: typeof ApiAdminBracketsLockRoute + ApiAdminBracketsUnlockRoute: typeof ApiAdminBracketsUnlockRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/test': { + id: '/test' + path: '/test' + fullPath: '/test' + preLoaderRoute: typeof TestRouteImport + parentRoute: typeof rootRouteImport + } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -58,6 +235,48 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/bracket/$username': { + id: '/bracket/$username' + path: '/bracket/$username' + fullPath: '/bracket/$username' + preLoaderRoute: typeof BracketUsernameRouteImport + parentRoute: typeof rootRouteImport + } + '/api/predictions/': { + id: '/api/predictions/' + path: '/api/predictions' + fullPath: '/api/predictions/' + preLoaderRoute: typeof ApiPredictionsIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/predictions/lock': { + id: '/api/predictions/lock' + path: '/api/predictions/lock' + fullPath: '/api/predictions/lock' + preLoaderRoute: typeof ApiPredictionsLockRouteImport + parentRoute: typeof rootRouteImport + } + '/api/og/$username': { + id: '/api/og/$username' + path: '/api/og/$username' + fullPath: '/api/og/$username' + preLoaderRoute: typeof ApiOgUsernameRouteImport + parentRoute: typeof rootRouteImport + } + '/api/leaderboard/calculate': { + id: '/api/leaderboard/calculate' + path: '/api/leaderboard/calculate' + fullPath: '/api/leaderboard/calculate' + preLoaderRoute: typeof ApiLeaderboardCalculateRouteImport + parentRoute: typeof rootRouteImport + } + '/api/bracket/$username': { + id: '/api/bracket/$username' + path: '/api/bracket/$username' + fullPath: '/api/bracket/$username' + preLoaderRoute: typeof ApiBracketUsernameRouteImport + parentRoute: typeof rootRouteImport + } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -65,12 +284,52 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/admin/users': { + id: '/api/admin/users' + path: '/api/admin/users' + fullPath: '/api/admin/users' + preLoaderRoute: typeof ApiAdminUsersRouteImport + parentRoute: typeof rootRouteImport + } + '/api/admin/check': { + id: '/api/admin/check' + path: '/api/admin/check' + fullPath: '/api/admin/check' + preLoaderRoute: typeof ApiAdminCheckRouteImport + parentRoute: typeof rootRouteImport + } + '/api/admin/brackets/unlock': { + id: '/api/admin/brackets/unlock' + path: '/api/admin/brackets/unlock' + fullPath: '/api/admin/brackets/unlock' + preLoaderRoute: typeof ApiAdminBracketsUnlockRouteImport + parentRoute: typeof rootRouteImport + } + '/api/admin/brackets/lock': { + id: '/api/admin/brackets/lock' + path: '/api/admin/brackets/lock' + fullPath: '/api/admin/brackets/lock' + preLoaderRoute: typeof ApiAdminBracketsLockRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AdminRoute: AdminRoute, + TestRoute: TestRoute, + BracketUsernameRoute: BracketUsernameRoute, + ApiAdminCheckRoute: ApiAdminCheckRoute, + ApiAdminUsersRoute: ApiAdminUsersRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, + ApiBracketUsernameRoute: ApiBracketUsernameRoute, + ApiLeaderboardCalculateRoute: ApiLeaderboardCalculateRoute, + ApiOgUsernameRoute: ApiOgUsernameRoute, + ApiPredictionsLockRoute: ApiPredictionsLockRoute, + ApiPredictionsIndexRoute: ApiPredictionsIndexRoute, + ApiAdminBracketsLockRoute: ApiAdminBracketsLockRoute, + ApiAdminBracketsUnlockRoute: ApiAdminBracketsUnlockRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 127f434..270335c 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,5 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRootRoute, HeadContent, @@ -6,12 +7,23 @@ import { Scripts, } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; +import { AdminButton } from "@/components/AdminButton"; import { createIsomorphicFn } from "@tanstack/react-start"; import { getRequestUrl } from "@tanstack/react-start/server"; import { Footer } from "@/components/footer/Footer"; import { Header } from "@/components/Header"; +import { NotFound } from "@/components/NotFound"; import appCss from "../styles/styles.css?url"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 min default + retry: 2, + }, + }, +}); + const getLocation = createIsomorphicFn() .server(() => getRequestUrl()) .client(() => new URL(window.location.href)); @@ -118,15 +130,6 @@ export const Route = createRootRoute({ notFoundComponent: NotFound, }); -function NotFound() { - return ( -
-

404 - Page Not Found

-

The page you're looking for doesn't exist.

-
- ); -} - function RootDocument() { return ( @@ -134,22 +137,25 @@ function RootDocument() { -
- -