leaderboard and ranked systems

This commit is contained in:
2026-07-11 11:13:28 -04:00
commit 02ac5f9593
44 changed files with 12318 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+142
View File
@@ -0,0 +1,142 @@
# Ranked EQAO — Claude Code Guide
## Project Overview
A ranked quiz app based on EQAO (Ontario standardized tests).
Users sign in with Google, answer questions by grade and subject,
and compete on leaderboards.
## Tech Stack
- **Runtime:** Node.js + Express
- **Database:** PostgreSQL via Prisma ORM
- **Auth:** Google OAuth 2.0 (Passport.js) + JWT
- **AI:** Gemini API (passage + question generation for English; answer checking)
- **Frontend:** React (Vite) — `frontend/` directory
## Grades
Only three grades are supported: **3, 6, 9**
- In the schema these are the `Grade` enum: `G3`, `G6`, `G9`
- Never use raw integers for grade — always use the enum
## Subjects
Two sections per quiz: `MATH` and `ENGLISH` (reading + writing combined)
- The Prisma `Subject` enum currently has `MATH`, `READING`, `WRITING` — these remain as-is for DB storage
- In the UI and game logic, treat READING + WRITING as a single **English** section
## Project Structure
```
/
├── prisma/
│ └── schema.prisma # All DB models
├── src/
│ ├── db.js # Prisma singleton
│ ├── auth/
│ │ └── google.js # Google OAuth + JWT issuing
│ ├── middleware/
│ │ └── auth.js # JWT verification middleware
│ └── services/
│ └── leaderboard.js # Leaderboard upsert + query logic
├── frontend/
│ └── src/
│ ├── pages/
│ │ ├── Login.jsx # Pre-login landing page
│ │ ├── Dashboard.jsx # Post-login dashboard (to be built)
│ │ └── MathGame.jsx # SCRAPPED — delete this file
│ ├── App.jsx
│ ├── index.css
│ └── main.jsx
├── .env # Never commit this
├── CLAUDE.md
└── package.json
```
## Environment Variables
| Variable | Purpose |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
| `GOOGLE_CLIENT_ID` | Google OAuth app client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth app client secret |
| `GOOGLE_CALLBACK_URL` | OAuth redirect URI |
| `JWT_SECRET` | Secret for signing JWTs |
| `GEMINI_API_KEY` | Gemini API key for English question/passage generation and answer checking |
| `PORT` | Express server port (default 3000) |
| `FRONTEND_URL` | Frontend origin for CORS |
## Key Conventions
- Always use `Grade` enum (`G3`, `G6`, `G9`) — never raw numbers
- Always use `Subject` enum (`MATH`, `READING`, `WRITING`)
- All routes that touch user data require the `auth.js` JWT middleware
- Leaderboard is scoped per `[userId, grade, subject]` — unique constraint enforced in DB
- Sessions track full answer history via `UserAnswer` for future analytics
## Database Commands
```bash
npx prisma migrate dev --name <migration_name> # new migration
npx prisma generate # regenerate client after schema change
npx prisma studio # visual DB browser
```
---
## Frontend Pages
### Login.jsx (Pre-login Landing Page) — KEEP, DO NOT ADD DEBUG BUTTON
- The current pre-login landing page and Google sign-in button are good — preserve this design
- The page will eventually be a longer scrollable page with a top navigation bar linking to info sections and other useful tabs (TBD before release)
- **DO NOT add any debug/bypass button that skips Google login** — all such buttons must be removed if found anywhere in the codebase
- For now, leave the landing page as-is; extended layout/nav is a pre-release task
### MathGame.jsx — SCRAPPED, DELETE THIS FILE
- `frontend/src/pages/MathGame.jsx` is fully scrapped — delete it
- Remove all routes, imports, and links pointing to MathGame anywhere in the codebase
- The actual EQAO quiz experience will be built fresh (see Quiz UI section below)
### Dashboard.jsx (Post-login) — TO BE BUILT
- Replaces the current "Coming Soon" placeholder
- Should be a rich, well-designed dashboard with multiple sections/widgets, for example:
- User greeting with avatar
- Stats summary (games played, best scores, rank per grade)
- Leaderboard preview
- Recent activity
- A **"Start Game"** button (prominent CTA) — the button exists in the UI but does NOT wire up to the quiz yet; the quiz flow is not being programmed at this stage
- The exact set of dashboard widgets/tabs is TBD — design it to look complete and polished even if most data is placeholder
---
## Quiz UI (To Be Built — Design Reference)
### General Flow
The quiz covers **two sections**: Math and English. The user completes them in order.
After finishing a section, the app checks answers and shows one of two outcomes:
- **100% correct** → proceed to the next section (or end the quiz if both are done)
- **At least 1 wrong** → must redo the section; keep retrying until 100% correct before advancing
There is no partial credit / moving on with mistakes — the player must get a perfect section to continue.
### Math Section
- Questions are **pre-generated** (not from Gemini) but with **randomised values each time** so the same question template produces different numbers on each attempt
- Questions should be creative and contextual — avoid trivial `a + b` style arithmetic; think word problems, patterns, multi-step reasoning appropriate to the grade level
- Multiple choice format (matching the original EQAO UI style — see design reference below)
### English Section
- **Gemini API** generates a reading passage appropriate for the grade
- Gemini also generates comprehension questions based on that passage
- Gemini checks free-response and paragraph writing answers (not just multiple choice)
- The section combines reading comprehension and a short writing component
### Quiz UI Design
- The actual in-quiz UI (question display, answer choices, progress) should stay **visually identical or very close to the original MathGame design** — same layout, card style, fonts, colour scheme
- Reference the existing `index.css` (`.math-game`, `.choice-btn`, `.grade-btn`, etc.) and replicate that look for the new quiz component
- Upload of reference screenshots is pending — when provided, match them closely
---
## What's Not Built Yet
- [ ] API routes (auth, sessions, leaderboard, questions)
- [ ] Gemini service for English passage + question generation and answer checking
- [BUILT] Math question templates with randomised values
- [BUILT] Quiz game component (replaces scrapped MathGame.jsx)
- [HALF-BUILT] Post-login Dashboard (rich version, not "Coming Soon" placeholder)
- [HALF-BUILT] Pre-login landing page extended layout + top nav (pre-release)
- [ ] Question seeding/storage strategy
- [ ] Frontend wiring for "Start Game" button
+3
View File
@@ -0,0 +1,3 @@
.env
.DS_Store
.claude
+1
View File
@@ -0,0 +1 @@
RANKED EQAO
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ranked EQAO</title>
<meta name="description" content="Ranked quiz battles based on Ontario EQAO standardized tests. Sign in and compete." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Russo+One&family=Barlow+Condensed:wght@300;400;500;600;700&family=Barlow:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+962
View File
@@ -0,0 +1,962 @@
{
"name": "frontend",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@vitejs/plugin-react": "^6.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.0"
},
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^8.0.10"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.128.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
"integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
"integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
"integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
"integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
"integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
"integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
"integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
"integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
"integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
"integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.7"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.6"
}
},
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
"integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz",
"integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.15.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
"integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.128.0",
"@rolldown/pluginutils": "1.0.0-rc.18"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.18",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
"@rolldown/binding-darwin-x64": "1.0.0-rc.18",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
"integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"rolldown": "1.0.0-rc.18",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~6.0.2",
"vite": "^8.0.10"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.0"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+46
View File
@@ -0,0 +1,46 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from './context/ThemeContext';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Game from './pages/Game';
import Queue from './pages/Queue';
import Settings from './pages/Settings';
function getValidStoredToken() {
const token = sessionStorage.getItem('ranked_token');
if (!token) return null;
try {
const { exp } = JSON.parse(atob(token.split('.')[1]));
if (exp * 1000 <= Date.now()) {
sessionStorage.removeItem('ranked_token');
return null;
}
return token;
} catch {
sessionStorage.removeItem('ranked_token');
return null;
}
}
// OAuth callback lands at /dashboard?token=<jwt> — token is in the URL, not yet
// in sessionStorage, so we must accept it here or the guard bounces the user back to /.
function hasUrlToken() {
return !!new URLSearchParams(window.location.search).get('token');
}
export default function App() {
const loggedIn = !!getValidStoredToken();
return (
<ThemeProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={loggedIn ? <Navigate to="/dashboard" replace /> : <Login />} />
<Route path="/dashboard" element={(loggedIn || hasUrlToken()) ? <Dashboard /> : <Navigate to="/" replace />} />
<Route path="/game" element={(loggedIn || hasUrlToken()) ? <Game />: <Navigate to="/" replace />} />
<Route path="/queue" element={(loggedIn || hasUrlToken()) ? <Queue /> : <Navigate to="/" replace />} />
<Route path="/settings" element={(loggedIn || hasUrlToken()) ? <Settings /> : <Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
);
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 575 KiB

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
const API = import.meta.env.VITE_API_URL;
const GRADES = [
{ id: 'G3', label: 'Grade 3' },
{ id: 'G6', label: 'Grade 6' },
{ id: 'G9', label: 'Grade 9' },
];
function fmtTime(seconds) {
if (seconds == null) return '—';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function TrophyIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
);
}
export default function LeaderboardModal({ isOpen, onClose }) {
const [selectedGrade, setSelectedGrade] = useState('G3');
const [leaderboard, setLeaderboard] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
setLoading(true);
fetch(`${API}/leaderboard?grade=${selectedGrade}&sortBy=bestTime&limit=10`)
.then(res => res.json())
.then(data => setLeaderboard(Array.isArray(data) ? data : []))
.catch(() => setLeaderboard([]))
.finally(() => setLoading(false));
}, [isOpen, selectedGrade]);
useEffect(() => {
if (!isOpen) return;
const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
const MEDALS = ['🥇', '🥈', '🥉'];
return (
<div className="lb-modal-overlay" onClick={onClose}>
<div className="lb-modal" onClick={e => e.stopPropagation()}>
<div className="lb-modal__hd">
<div className="lb-modal__title-row">
<span className="lb-modal__icon">
<TrophyIcon />
</span>
<h2 className="lb-modal__title">Best Times</h2>
</div>
<button className="lb-modal__close" onClick={onClose}></button>
</div>
<div className="lb-modal__grade-tabs">
{GRADES.map(g => (
<button
key={g.id}
className={`lb-modal__grade-tab${selectedGrade === g.id ? ' lb-modal__grade-tab--active' : ''}`}
onClick={() => setSelectedGrade(g.id)}
>
{g.label}
</button>
))}
</div>
<div className="lb-modal__body">
{loading ? (
<div className="lb-skeleton">
{[...Array(5)].map((_, i) => (
<div key={i} className="lb-skel-row" style={{ animationDelay: `${i * 0.07}s` }} />
))}
</div>
) : leaderboard.length === 0 ? (
<div className="dash-empty">
<span className="dash-empty__icon">🏆</span>
<p className="dash-empty__text">No scores yet</p>
<p className="dash-empty__sub">Be the first to set a record</p>
</div>
) : (
<div className="lb-table">
{leaderboard.map(entry => (
<div key={entry.rank} className="lb-row">
<span className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`}>
{entry.rank <= 3 ? MEDALS[entry.rank - 1] : entry.rank}
</span>
<div className="lb-user">
{entry.avatarUrl
? <img className="lb-avatar" src={entry.avatarUrl} alt={entry.displayName} referrerPolicy="no-referrer" />
: <div className="lb-avatar lb-avatar--fallback">{entry.displayName[0]?.toUpperCase() ?? '?'}</div>
}
<span className="lb-name">{entry.displayName}</span>
</div>
<span className="lb-score">{fmtTime(entry.bestTime)}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function ProfileDropdown({ displayName, avatarUrl, initial, onSignOut }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const navigate = useNavigate();
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
useEffect(() => {
if (!open) return;
const onClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
window.addEventListener('mousedown', onClick);
return () => window.removeEventListener('mousedown', onClick);
}, [open]);
return (
<div className="profile-dropdown" ref={ref}>
<button className="profile-dropdown__trigger" onClick={() => setOpen(o => !o)}>
{avatarUrl
? <img className="dash-nav__avatar" src={avatarUrl} alt={displayName} referrerPolicy="no-referrer" />
: <div className="dash-nav__avatar dash-nav__avatar--fallback">{initial}</div>
}
<span className="dash-nav__name">{displayName}</span>
</button>
{open && (
<div className="profile-dropdown__menu">
<div className="profile-dropdown__user">
{avatarUrl
? <img className="profile-dropdown__avatar" src={avatarUrl} alt={displayName} referrerPolicy="no-referrer" />
: <div className="profile-dropdown__avatar profile-dropdown__avatar--fallback">{initial}</div>
}
<span className="profile-dropdown__name">{displayName}</span>
</div>
<div className="profile-dropdown__divider" />
<button className="profile-dropdown__item" onClick={() => { setOpen(false); navigate('/settings'); }}>
Settings
</button>
<button className="profile-dropdown__item profile-dropdown__item--danger" onClick={onSignOut}>
Sign out
</button>
</div>
)}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { createContext, useContext, useEffect, useState } from 'react';
const THEME_KEY = 'ranked_theme';
function getInitialTheme() {
try {
const stored = localStorage.getItem(THEME_KEY);
if (stored === 'light' || stored === 'dark') return stored;
} catch {}
return 'dark';
}
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem(THEME_KEY, theme); } catch {}
}, [theme]);
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
+405
View File
@@ -0,0 +1,405 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ProfileDropdown from '../components/ProfileDropdown';
const API = import.meta.env.VITE_API_URL;
const GRADES = [
{ id: 'G3', label: 'Grade 3' },
{ id: 'G6', label: 'Grade 6' },
{ id: 'G9', label: 'Grade 9' },
];
function decodeJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
}
function fmtTime(ms) {
if (ms == null) return '—';
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function StatCard({ label, value, accent = false }) {
return (
<div className="stat-card">
<span className="stat-card__label">{label}</span>
<span className={`stat-card__value${accent ? ' stat-card__value--accent' : ''}`}>
{value}
</span>
</div>
);
}
const RANK_STYLES = [
{ color: '#f5c518', textShadow: '0 0 12px rgba(245,197,24,0.6)' },
{ color: '#94a3b8', textShadow: '0 0 10px rgba(148,163,184,0.5)' },
{ color: '#c9824a', textShadow: '0 0 10px rgba(201,130,74,0.5)' },
];
function LeaderboardTable({ entries, loading, highlightRank, grade }) {
if (loading) {
return (
<div className="lb-skeleton">
{[...Array(5)].map((_, i) => (
<div key={i} className="lb-skel-row" style={{ animationDelay: `${i * 0.07}s` }} />
))}
</div>
);
}
if (!entries.length) {
const gradeLabel = GRADES.find(g => g.id === grade)?.label ?? grade;
return (
<div className="dash-empty">
<span className="dash-empty__icon"></span>
<p className="dash-empty__text">No times recorded yet for {gradeLabel}</p>
<p className="dash-empty__sub">Complete a quiz to appear on the board</p>
</div>
);
}
return (
<div className="lb-table">
<div className="lb-header">
<span className="lb-header__rank">Rank</span>
<span className="lb-header__player">Player</span>
<span className="lb-header__time">Best Time</span>
</div>
{entries.map(entry => {
const isCurrent = highlightRank !== null && entry.rank === highlightRank;
const rankStyle = entry.rank <= 3 ? RANK_STYLES[entry.rank - 1] : null;
const playerLabel = entry.username ? `@${entry.username}` : entry.displayName;
const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?';
return (
<div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`}>
<span
className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`}
style={rankStyle ?? undefined}
>
{entry.rank}
</span>
<div className="lb-user">
{entry.avatarUrl
? <img className="lb-avatar" src={entry.avatarUrl} alt={playerLabel} referrerPolicy="no-referrer" />
: <div className="lb-avatar lb-avatar--fallback">{fallbackLetter}</div>
}
<span className="lb-name">{playerLabel}</span>
{isCurrent && <span className="lb-you">YOU</span>}
</div>
<span className="lb-score lb-score--time">{fmtTime(entry.bestTime)}</span>
</div>
);
})}
</div>
);
}
function UsernameModal({ token, current, onClose, onSave }) {
const [value, setValue] = useState(current ?? '');
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
async function handleSave() {
const trimmed = value.trim();
if (!trimmed) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`${API}/me/username`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ username: trimmed }),
});
const data = await res.json();
if (res.ok) { onSave(data.username); onClose(); }
else if (data.error === 'taken') setError('Username already taken — try another.');
else setError('320 characters, letters, numbers, and underscores only.');
} catch {
setError('Could not save. Check your connection.');
} finally {
setSaving(false);
}
}
return (
<div className="uname-overlay" onMouseDown={onClose}>
<div className="uname-modal" onMouseDown={e => e.stopPropagation()}>
<h3 className="uname-modal__title">Set username</h3>
<p className="uname-modal__hint">320 chars · letters, numbers, underscores</p>
<input
ref={inputRef}
className="uname-input"
value={value}
onChange={e => { setValue(e.target.value); setError(null); }}
onKeyDown={e => e.key === 'Enter' && handleSave()}
placeholder="e.g. player_one"
maxLength={20}
/>
{error && <p className="uname-error">{error}</p>}
<div className="uname-actions">
<button className="uname-btn uname-btn--cancel" onClick={onClose}>Cancel</button>
<button className="uname-btn uname-btn--save" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
}
function LoadingSkeleton() {
return (
<>
<div className="arena-bg"><div className="arena-grain" /></div>
<nav className="dash-nav">
<div className="skel-pill" style={{ width: 140, height: 22 }} />
<div className="skel-pill" style={{ width: 180, height: 36, borderRadius: 999 }} />
</nav>
<main className="page dash-page">
<section className="dash-hero">
<div className="skel-pill" style={{ width: 96, height: 12, marginBottom: 16 }} />
<div className="skel-pill" style={{ width: 300, height: 64, marginBottom: 32 }} />
<div style={{ display: 'flex', gap: 8 }}>
{[0, 1, 2].map(i => (
<div key={i} className="skel-pill" style={{ width: 96, height: 38, animationDelay: `${i * 0.08}s` }} />
))}
</div>
</section>
<div className="dash-cta">
<div className="skel-pill" style={{ width: 220, height: 58 }} />
</div>
<div className="dash-stats">
{[0, 1, 2].map(i => (
<div key={i} className="skel-pill" style={{ height: 88, animationDelay: `${i * 0.08}s` }} />
))}
</div>
</main>
</>
);
}
export default function Dashboard() {
const navigate = useNavigate();
const [token, setToken] = useState(null);
const [jwtPayload, setJwtPayload] = useState(null);
const [user, setUser] = useState(null);
const [username, setUsername] = useState(null);
const [usernameModal, setUsernameModal] = useState(false);
const [stats, setStats] = useState([]);
const [leaderboard, setLeaderboard] = useState([]);
const [selectedGrade, setSelectedGrade] = useState('G3');
const [loading, setLoading] = useState(true);
const [lbLoading, setLbLoading] = useState(false);
const [error, setError] = useState(null);
const profileElo = stats.find(e => e.grade === selectedGrade)?.elo ?? null;
// ── Auth init ──
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
let tok = urlToken;
if (urlToken) {
sessionStorage.setItem('ranked_token', urlToken);
history.replaceState(null, '', window.location.pathname);
} else {
tok = sessionStorage.getItem('ranked_token');
}
if (!tok) { navigate('/'); return; }
setToken(tok);
setJwtPayload(decodeJwt(tok));
}, [navigate]);
// ── Fetch user + stats ──
useEffect(() => {
if (!token) return;
const headers = { Authorization: `Bearer ${token}` };
Promise.all([
fetch(`${API}/auth/me`, { headers }),
fetch(`${API}/me/stats`, { headers }),
])
.then(async ([meRes, statsRes]) => {
if (meRes.status === 401) {
sessionStorage.removeItem('ranked_token');
setLoading(false);
navigate('/');
return;
}
const [meData, statsData] = await Promise.all([meRes.json(), statsRes.json()]);
setUser(meData);
setUsername(meData.username ?? null);
setStats(statsData.entries ?? []);
setLoading(false);
})
.catch(() => {
setError('Could not reach the server. Check your connection and refresh.');
setLoading(false);
});
}, [token, navigate]);
// ── Fetch leaderboard ──
useEffect(() => {
if (!token) return;
setLbLoading(true);
fetch(`${API}/leaderboard?grade=${selectedGrade}`)
.then(res => res.json())
.then(data => setLeaderboard(Array.isArray(data) ? data : []))
.catch(() => setLeaderboard([]))
.finally(() => setLbLoading(false));
}, [token, selectedGrade]);
const signOut = () => {
sessionStorage.removeItem('ranked_token');
navigate('/');
};
const currentEntry = stats.find(e => e.grade === selectedGrade);
const highlightRank = currentEntry?.timeRank ?? null;
const displayName = user?.displayName ?? jwtPayload?.name ?? 'Player';
const firstName = displayName.split(' ')[0];
const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null;
const initial = displayName[0]?.toUpperCase() ?? '?';
if (loading) return <LoadingSkeleton />;
return (
<>
<div className="arena-bg"><div className="arena-grain" /></div>
{usernameModal && (
<UsernameModal
token={token}
current={username}
onClose={() => setUsernameModal(false)}
onSave={(u) => setUsername(u)}
/>
)}
{/* ── Nav ── */}
<nav className="dash-nav">
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
{profileElo !== null && <span style={{
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
color: 'var(--gold)', letterSpacing: '0.04em',
padding: '4px 12px', borderRadius: 999,
background: 'var(--gold-dim)',
border: '1px solid var(--gold-mid)',
}}>
ELO: {profileElo.toLocaleString()}
</span>}
</div>
<div className="dash-nav__right">
{username
? <button className="dash-nav__username" onClick={() => setUsernameModal(true)}>@{username}</button>
: <button className="dash-nav__set-username" onClick={() => setUsernameModal(true)}>Set username</button>
}
<ProfileDropdown
displayName={displayName}
avatarUrl={avatarUrl}
initial={initial}
onSignOut={signOut}
/>
</div>
</nav>
<main className="page dash-page">
{/* ── Hero ── */}
<section className="dash-hero">
<h1 className="dash-hero__title">{username ?? firstName}</h1>
<div className="dash-grade-tabs">
{GRADES.map(g => (
<button
key={g.id}
className={`dash-grade-tab${selectedGrade === g.id ? ' dash-grade-tab--active' : ''}`}
onClick={() => setSelectedGrade(g.id)}
>
{g.label}
</button>
))}
</div>
</section>
{/* ── CTA Buttons ── */}
<div className="dash-cta">
<button className="dash-cta__btn" onClick={() => navigate('/game', { state: { grade: selectedGrade } })}>
<span className="dash-cta__label">Start Game</span>
</button>
<button className="dash-cta__btn dash-cta__btn--ranked" onClick={() => navigate('/queue', { state: { grade: selectedGrade } })}>
<span className="dash-cta__label">Queue Ranked</span>
</button>
</div>
{/* ── Error banner ── */}
{error && (
<div className="dash-error-banner">
<span>{error}</span>
<button className="dash-error-banner__dismiss" onClick={() => setError(null)}></button>
</div>
)}
{/* ── Stats row ── */}
<div className="dash-stats">
<StatCard
label="Best Time"
value={fmtTime(currentEntry?.bestTime)}
accent
/>
<StatCard
label="Games Played"
value={currentEntry?.gamesPlayed ?? '—'}
/>
<StatCard
label="ELO"
value={currentEntry?.elo?.toLocaleString() ?? '—'}
/>
</div>
{/* ── Leaderboard ── */}
<section className="dash-section">
<div className="dash-section__hd">
<h2 className="dash-section__title">Leaderboard</h2>
<span className="dash-section__badge">Best Time</span>
</div>
<div className="dash-card">
<LeaderboardTable
entries={leaderboard}
loading={lbLoading}
highlightRank={highlightRank}
grade={selectedGrade}
/>
</div>
</section>
{/* ── Recent Activity ── */}
<section className="dash-section">
<div className="dash-section__hd">
<h2 className="dash-section__title">Recent Activity</h2>
</div>
<div className="dash-card">
<div className="dash-empty">
<span className="dash-empty__icon">🎮</span>
<p className="dash-empty__text">No games played yet</p>
<p className="dash-empty__sub">Complete a game to see your activity here</p>
</div>
</div>
</section>
</main>
</>
);
}
+468
View File
@@ -0,0 +1,468 @@
import { useState, useEffect, useRef } from 'react';
import treeBg from '../assets/tree_bg_svg.svg';
import { useNavigate, useLocation } from 'react-router-dom';
const API = import.meta.env.VITE_API_URL;
const STAGE_SIZE = 11;
function fmtDuration(ms) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
// ── ProgressStrip ──────────────────────────────────────────────
function ProgressStrip({ total, current, answers, onJump }) {
const items = [];
for (let qi = 0; qi < total; qi++) {
const active = qi === current;
const answered = answers[qi] != null;
const filled = answered;
items.push(
<button
key={`b-${qi}`}
onClick={() => onJump && onJump(qi)}
style={{
width: 36, height: 36, borderRadius: '50%',
border: `2px solid ${filled || active ? '#1a73e8' : '#ccc'}`,
background: filled ? '#1a73e8' : active ? '#e8f0fe' : '#fff',
color: filled ? '#fff' : active ? '#1a73e8' : '#aaa',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif',
flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box',
transition: 'background 0.15s, border-color 0.15s',
}}
>
{qi + 1}
</button>
);
if (qi < total - 1) {
items.push(
<div
key={`l-${qi}`}
style={{ flex: 1, height: 3, minWidth: 2, background: answers[qi] != null ? '#1a73e8' : '#ddd' }}
/>
);
}
}
return (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%' }}>
{items}
</div>
);
}
const BTN = {
padding: '8px 22px', borderRadius: 999, fontFamily: 'system-ui,sans-serif',
fontSize: 14, fontWeight: 600, cursor: 'pointer',
border: '1px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8',
};
const NAV_BTN = {
padding: '14px 40px', borderRadius: 999, fontFamily: 'system-ui,sans-serif',
fontSize: 17, fontWeight: 600, cursor: 'pointer',
border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8',
};
const WRAP = { display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' };
// ── Screens ────────────────────────────────────────────────────
function StageIntro({ stage, onNext }) {
return (
<div style={{ display: 'flex', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' }}>
<div style={{ width: '50%', flexShrink: 0, overflow: 'hidden' }}>
<img src={treeBg} alt="" aria-hidden="true" style={{ width: '100%', height: '100vh', objectFit: 'cover', objectPosition: 'center', display: 'block' }} />
</div>
<div style={{ width: '50%', display: 'flex', flexDirection: 'column', padding: '60px 48px', justifyContent: 'space-between' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 22 }}>
<p style={{ fontSize: 11, color: '#999', letterSpacing: 3, textTransform: 'uppercase', margin: 0 }}>Mathematics Stage {stage}</p>
<h1 style={{ fontSize: 'clamp(28px,3.5vw,42px)', fontWeight: 700, color: '#111', lineHeight: 1.35, margin: 0 }}>
You are ready to begin Stage {stage} in Mathematics.
</h1>
<p style={{ fontSize: 'clamp(17px,2vw,22px)', color: '#555', margin: 0 }}>Answer the next {STAGE_SIZE} questions to complete this stage.</p>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 40, marginRight: 32 }}>
<button onClick={onNext} style={{ ...BTN, padding: '18px 48px', fontSize: 22, borderRadius: 10 }}>Next </button>
</div>
</div>
</div>
);
}
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump }) {
useEffect(() => {
const handler = (e) => {
const i = +e.key - 1;
if (i >= 0 && i < q.choices.length) { onSelect(i); return; }
if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext();
if (e.key === 'ArrowLeft' && qIdx > 0) onBack();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
return (
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', boxShadow: '0 0 40px rgba(0,60,120,0.10)' }}>
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '24px 32px 20px' }}>
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 20px 0', lineHeight: 1 }}>
Question {qIdx + 1} of {STAGE_SIZE}
</p>
<ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} />
</div>
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>
{q.question}
</p>
<div style={q.layout === 'grid'
? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }
: { display: 'flex', flexDirection: 'column', gap: 12 }
}>
{q.choices.map((choice, i) => {
const sel = selected === i;
return (
<button
key={i}
onClick={() => onSelect(i)}
style={{
minHeight: 64, padding: '18px 24px',
background: sel ? '#e8f0fe' : '#f7f8fa',
border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`,
borderRadius: 12, fontSize: 20,
color: sel ? '#1a73e8' : '#222',
textAlign: 'left', cursor: 'pointer',
fontWeight: sel ? 600 : 400,
fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45,
transition: 'background 0.12s, border-color 0.12s',
}}
>
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>
{i + 1}
</span>
{choice}
</button>
);
})}
</div>
</div>
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'flex-end', gap: 14 }}>
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}> Back</button>}
{qIdx === STAGE_SIZE - 1 ? (
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>
Submit
</button>
) : (
<button style={NAV_BTN} onClick={onNext}>Next </button>
)}
</div>
</div>
</div>
);
}
function StageResult({ stage, wrongCount, questions, answers: initialAnswers, onRetry, onNext }) {
const [viewIdx, setViewIdx] = useState(0);
const [localAnswers, setLocalAnswers] = useState(initialAnswers);
const q = questions[viewIdx];
const storedAnswer = localAnswers[viewIdx] ?? null;
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div style={{ padding: '28px 40px 0' }}>
<h2 style={{ fontSize: 22, fontWeight: 700, color: '#111', margin: 0 }}>Mathematics</h2>
<hr style={{ border: 'none', borderTop: '1px solid #e0e0e0', margin: '16px 0 0' }} />
</div>
{wrongCount > 0 && (
<div style={{ padding: '18px 40px 0' }}>
<p style={{ fontSize: 15, fontWeight: 600, color: '#b00020', margin: 0 }}>
{wrongCount} question{wrongCount > 1 ? 's' : ''} were incorrect.
</p>
</div>
)}
<div style={{ display: 'flex', flex: 1, padding: '24px 40px 100px', gap: 40 }}>
<div style={{ width: '40%', display: 'flex', flexDirection: 'column', gap: 18 }}>
<p style={{ fontSize: 18, color: '#111', lineHeight: 1.75, whiteSpace: 'pre-line', fontWeight: 500, margin: 0 }}>{q.question}</p>
<div style={q.layout === 'grid'
? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }
: { display: 'flex', flexDirection: 'column', gap: 10 }
}>
{q.choices.map((choice, i) => {
const sel = storedAnswer === i;
return (
<button
key={i}
onClick={() => setLocalAnswers(prev => ({ ...prev, [viewIdx]: i }))}
style={{
minHeight: 52, padding: '13px 18px',
background: sel ? '#e8f0fe' : '#f7f8fa',
border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`,
borderRadius: 10, fontSize: 15,
color: sel ? '#1a73e8' : '#222',
textAlign: 'left', cursor: 'pointer',
fontWeight: sel ? 600 : 400,
fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45,
transition: 'background 0.12s, border-color 0.12s',
}}
>
<span style={{ fontSize: 12, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 14, opacity: 0.7 }}>{i + 1}</span>
{choice}
</button>
);
})}
</div>
<div style={{ background: '#f5f5f5', borderRadius: 8, padding: '10px 16px', fontSize: 13, color: '#666' }}>
Your answer: {storedAnswer !== null ? q.choices[storedAnswer] : '—'}
</div>
</div>
<div style={{ width: '60%', display: 'flex', flexDirection: 'column', gap: 20 }}>
<h3 style={{ fontSize: 20, fontWeight: 700, color: '#111', margin: 0 }}>Stage {stage}</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
{questions.map((_, qi) => {
const active = qi === viewIdx;
return (
<button
key={qi}
onClick={() => setViewIdx(qi)}
style={{
padding: '10px 14px',
background: active ? '#1a73e8' : '#fff',
color: active ? '#fff' : '#1a73e8',
border: active ? '2px solid #1a73e8' : '1.5px solid #1a73e8',
borderRadius: 8, fontSize: 14, fontWeight: 600,
cursor: 'pointer', fontFamily: 'system-ui,-apple-system,sans-serif',
}}
>
Question {qi + 1}
</button>
);
})}
</div>
</div>
</div>
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', padding: '0 40px', background: '#fff', borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', zIndex: 100 }}>
{wrongCount === 0
? <button style={NAV_BTN} onClick={onNext}>Continue </button>
: <button style={NAV_BTN} onClick={onRetry}>Try Again</button>
}
</div>
</div>
);
}
function CompleteScreen({ duration, grade, onDashboard }) {
const [pbState, setPbState] = useState(null);
useEffect(() => {
const token = sessionStorage.getItem('ranked_token');
if (!token || !grade) { setPbState({ updated: false }); return; }
fetch(`${API}/me/best-time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ grade, timeMs: duration }),
})
.then(r => r.json())
.then(data => setPbState({ updated: data.updated ?? false }))
.catch(() => setPbState({ updated: false }));
}, []);
return (
<div style={WRAP}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
<div style={{ fontSize: 60 }}>🎉</div>
<h1 style={{ fontSize: 30, fontWeight: 800, color: '#111' }}>Quiz Complete!</h1>
<p style={{ fontSize: 17, color: '#555' }}>You completed both stages of the Mathematics quiz.</p>
<div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}>
{fmtDuration(duration)}
</div>
{pbState?.updated && (
<div style={{ padding: '10px 28px', background: 'rgba(245,197,24,0.12)', border: '1.5px solid rgba(245,197,24,0.5)', borderRadius: 10, fontSize: 15, fontWeight: 700, color: '#b8860b', letterSpacing: '0.04em' }}>
New Personal Best!
</div>
)}
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>
Back to Dashboard
</button>
</div>
</div>
);
}
// ── Game Controller ────────────────────────────────────────────
export default function Game() {
const navigate = useNavigate();
const { state } = useLocation();
const grade = state?.grade ?? null;
const [phase, setPhase] = useState('loading');
const [stage, setStage] = useState(1);
const [questions, setQuestions] = useState([]);
const [qIdx, setQIdx] = useState(0);
const [answers, setAnswers] = useState({});
const [selected, setSelected] = useState(null);
const [wrongCount, setWrongCount] = useState(0);
const [duration, setDuration] = useState(0);
const [elapsed, setElapsed] = useState(0);
const [fetchError, setFetchError] = useState(false);
const timerStartRef = useRef(null);
const intervalRef = useRef(null);
const stagedQsRef = useRef({ 1: [], 2: [] });
const sessionIdRef = useRef(null);
const s1CorrectRef = useRef(0);
// Fetch both stages in parallel while the user reads the intro screen
useEffect(() => {
if (!grade) { navigate('/dashboard', { replace: true }); return; }
const token = sessionStorage.getItem('ranked_token');
const headers = { Authorization: `Bearer ${token}` };
Promise.all([
fetch(`${API}/questions?grade=${grade}`, { headers }).then(r => r.json()),
fetch(`${API}/questions?grade=${grade}`, { headers }).then(r => r.json()),
]).then(([s1, s2]) => {
stagedQsRef.current = { 1: s1, 2: s2 };
setQuestions(s1);
setPhase('intro');
}).catch(() => setFetchError(true));
}, [grade, navigate]);
useEffect(() => () => clearInterval(intervalRef.current), []);
function startTimer() {
timerStartRef.current = Date.now();
intervalRef.current = setInterval(() => setElapsed(Date.now() - timerStartRef.current), 500);
}
function stopTimer() {
clearInterval(intervalRef.current);
intervalRef.current = null;
return timerStartRef.current ? Date.now() - timerStartRef.current : 0;
}
if (!grade) return null;
if (fetchError) return (
<div style={{ ...WRAP, alignItems: 'center', justifyContent: 'center', gap: 16, textAlign: 'center' }}>
<p style={{ fontSize: 18, color: '#555' }}>Failed to load questions.</p>
<button onClick={() => navigate('/dashboard')} style={{ ...BTN, padding: '12px 32px' }}>Go back</button>
</div>
);
function loadStage(s) {
setQuestions(stagedQsRef.current[s]);
setStage(s);
setQIdx(0);
setAnswers({});
setSelected(null);
setPhase('intro');
}
function startSession() {
const token = sessionStorage.getItem('ranked_token');
fetch(`${API}/session/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ grade, subject: 'MATH' }),
}).then(r => r.json()).then(d => { sessionIdRef.current = d.sessionId ?? null; }).catch(() => {});
}
function endSession(totalCorrect) {
if (!sessionIdRef.current) return;
const token = sessionStorage.getItem('ranked_token');
fetch(`${API}/session/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ sessionId: sessionIdRef.current, correctAnswers: totalCorrect, totalQuestions: STAGE_SIZE * 2 }),
}).catch(() => {});
}
function handleNext() {
const newAnswers = { ...answers, [qIdx]: selected };
setAnswers(newAnswers);
if (qIdx < STAGE_SIZE - 1) {
const next = qIdx + 1;
setQIdx(next);
setSelected(newAnswers[next] ?? null);
} else {
const wrong = questions.filter((q, i) => newAnswers[i] !== q.correct).length;
setWrongCount(wrong);
setPhase('result');
}
}
function handleBack() {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
setAnswers(newAnswers);
const prev = qIdx - 1;
setQIdx(prev);
setSelected(newAnswers[prev] ?? null);
}
function handleJump(targetIdx) {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
setAnswers(newAnswers);
setQIdx(targetIdx);
setSelected(newAnswers[targetIdx] ?? null);
}
function handleRetry() {
setQIdx(0);
setSelected(answers[0] ?? null);
setPhase('question');
}
function handleResultNext() {
if (stage === 1) {
s1CorrectRef.current = STAGE_SIZE - wrongCount;
loadStage(2);
} else {
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
setDuration(stopTimer());
setPhase('complete');
}
}
const timerRunning = timerStartRef.current !== null && phase !== 'complete';
const timerSecs = Math.floor(elapsed / 1000);
const timerDisplay = `${String(Math.floor(timerSecs / 60)).padStart(2, '0')}:${String(timerSecs % 60).padStart(2, '0')}`;
function renderPhase() {
if (phase === 'loading') return (
<div style={{ ...WRAP, alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
if (phase === 'intro') return <StageIntro stage={stage} onNext={() => { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />;
if (phase === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} />;
if (phase === 'result') return <StageResult stage={stage} wrongCount={wrongCount} questions={questions} answers={answers} onRetry={handleRetry} onNext={handleResultNext} />;
if (phase === 'complete') return <CompleteScreen duration={duration} grade={grade} onDashboard={() => navigate('/dashboard')} />;
return null;
}
return (
<>
{timerRunning && (
<div style={{
position: 'fixed', top: 18, right: 24, zIndex: 200,
background: 'rgba(26,115,232,0.92)', color: '#fff',
fontFamily: 'system-ui,-apple-system,sans-serif',
fontSize: 15, fontWeight: 700, letterSpacing: '0.08em',
padding: '6px 14px', borderRadius: 999,
boxShadow: '0 2px 12px rgba(26,115,232,0.35)',
userSelect: 'none',
}}>
{timerDisplay}
</div>
)}
{renderPhase()}
</>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useState } from 'react';
import LeaderboardModal from '../components/LeaderboardModal';
const API = import.meta.env.VITE_API_URL;
/* Animated background shapes — generated once, stable positions */
const DIAMONDS = [
{ left: '8%', top: '15%', size: 14, dur: '9s', delay: '0s' },
{ left: '22%', top: '60%', size: 10, dur: '13s', delay: '2s' },
{ left: '55%', top: '25%', size: 18, dur: '11s', delay: '0.5s' },
{ left: '72%', top: '70%', size: 12, dur: '8s', delay: '3s' },
{ left: '88%', top: '35%', size: 16, dur: '14s', delay: '1s' },
{ left: '38%', top: '80%', size: 8, dur: '10s', delay: '4s' },
{ left: '64%', top: '12%', size: 11, dur: '12s', delay: '1.5s' },
];
const RINGS = [
{ left: '15%', top: '40%', size: 60, dur: '7s', delay: '0s' },
{ left: '80%', top: '20%', size: 44, dur: '9s', delay: '2.5s' },
{ left: '50%', top: '65%', size: 80, dur: '11s', delay: '1s' },
];
const SCANLINES = [
{ top: '30%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
{ top: '55%', width: '120px', left: '75%', dur: '10s', delay: '3s' },
{ top: '20%', width: '200px', left: '40%', dur: '12s', delay: '1.5s' },
];
export default function Login() {
const [lbOpen, setLbOpen] = useState(false);
const authError = new URLSearchParams(window.location.search).get('error') === 'auth_failed';
return (
<>
<LeaderboardModal isOpen={lbOpen} onClose={() => setLbOpen(false)} />
{/* Animated background */}
<div className="arena-bg">
<div className="arena-grain" />
<div className="arena-shapes">
{DIAMONDS.map((d, i) => (
<div
key={i}
className="shape shape--diamond"
style={{
left: d.left,
top: d.top,
width: d.size,
height: d.size,
animationDuration: d.dur,
animationDelay: d.delay,
}}
/>
))}
{RINGS.map((r, i) => (
<div
key={i}
className="shape shape--ring"
style={{
left: r.left,
top: r.top,
width: r.size,
height: r.size,
animationDuration: r.dur,
animationDelay: r.delay,
}}
/>
))}
{SCANLINES.map((s, i) => (
<div
key={i}
className="shape shape--line"
style={{
top: s.top,
left: s.left,
width: s.width,
animationDuration: s.dur,
animationDelay: s.delay,
}}
/>
))}
</div>
</div>
{/* Page content */}
<main className="page login">
<p className="login__eyebrow">Ontario Standardized Tests</p>
<div className="login__hero">
<h1 className="login__title">
<span>Ranked</span>
EQAO
</h1>
<p className="login__tagline">
Compete. Practice. <em>Dominate</em> your grade.
</p>
</div>
<div className="grade-badges">
{[
{ id: 'G3', label: 'Grade 3' },
{ id: 'G6', label: 'Grade 6' },
{ id: 'G9', label: 'Grade 9' },
].map(({ id, label }) => (
<div key={id} className="grade-badge">
<span className="grade-badge__rank">{id}</span>
<span className="grade-badge__label">{label}</span>
</div>
))}
</div>
<div className="login__cta">
{authError && (
<p className="login__error">Sign-in failed please try again.</p>
)}
<a href={`${API}/auth/google`} className="btn-google">
<GoogleIcon />
Sign in with Google
</a>
<p className="login__fine">Free · No credit card required</p>
</div>
<footer className="login__footer">
Ranked EQAO · Built for Ontario Students
</footer>
</main>
<button className="lb-trophy-btn" onClick={() => setLbOpen(true)} aria-label="Leaderboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
</button>
</>
);
}
function GoogleIcon() {
return (
<svg className="btn-google__icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}
+505
View File
@@ -0,0 +1,505 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
const STAGE_SIZE = 11;
const API = import.meta.env.VITE_API_URL;
const NAV_BTN = {
padding: '14px 40px', borderRadius: 999,
fontFamily: 'system-ui,sans-serif', fontSize: 17, fontWeight: 600,
cursor: 'pointer', border: '1.5px solid #b6d8f5',
background: '#e8f4fd', color: '#1a73e8',
};
function OpponentBar({ progress, total, name }) {
const pct = (progress / total) * 100;
return (
<div style={{ marginTop: 8, padding: '8px 12px', background: '#fef9f0', borderRadius: 8, border: '1px solid #fce4c8', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e67e22', whiteSpace: 'nowrap', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.02em' }}>{name}</span>
<div style={{ flex: 1, height: 8, borderRadius: 4, background: '#f5e6d3', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'linear-gradient(90deg, #f39c12, #e67e22)', borderRadius: 4, transition: 'width 0.6s ease' }} />
</div>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e67e22', minWidth: 36, textAlign: 'right', fontFamily: 'system-ui,sans-serif' }}>{Math.min(Math.round(progress), total)}/{total}</span>
</div>
);
}
function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) {
const [dots, setDots] = useState('');
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', border: '4px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Searching for opponent{dots}</h2>
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>Searching all players...</p>
<div style={{ fontSize: 14, color: '#666', fontFamily: 'system-ui,sans-serif', fontWeight: 500, letterSpacing: '0.02em' }}>
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
</div>
<button onClick={onBack} style={{ marginTop: 8, padding: '10px 32px', fontSize: 15, fontWeight: 600, background: '#f5f5f5', color: '#555', border: '1px solid #ccc', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}> Back to Dashboard</button>
{showForceMatch && (
<button onClick={onForceMatch} style={{ padding: '14px 36px', fontSize: 16, fontWeight: 700, background: '#ff69b4', color: '#000', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif', boxShadow: '0 0 8px #ff69b4aa' }}> Force Match</button>
)}
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
function PlayerChip({ name, avatar, reverse }) {
const pic = avatar
? <img src={avatar} alt="" style={{ width: 56, height: 56, borderRadius: '50%', flexShrink: 0 }} referrerPolicy="no-referrer" />
: <div style={{ width: 56, height: 56, borderRadius: '50%', background: '#1a73e8', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, fontWeight: 700, flexShrink: 0 }}>{name?.[0]?.toUpperCase() ?? '?'}</div>;
const label = <span style={{ fontSize: 17, fontWeight: 700, color: '#222', fontFamily: 'system-ui,sans-serif' }}>{name}</span>;
return (
<div style={{ display: 'flex', flexDirection: reverse ? 'row-reverse' : 'row', alignItems: 'center', gap: 12 }}>
{pic}{label}
</div>
);
}
function StageIntro({ grade, myName, myAvatar, opponentName, opponentAvatar, onNext }) {
const [count, setCount] = useState(5);
const gradeNum = grade?.replace('G', '') ?? '';
useEffect(() => {
const id = setInterval(() => {
setCount(c => {
if (c <= 1) { clearInterval(id); onNext(); return 0; }
return c - 1;
});
}, 1000);
return () => clearInterval(id);
}, [onNext]);
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 28 }}>
<p style={{ fontSize: 11, color: '#999', letterSpacing: 3, textTransform: 'uppercase', margin: 0 }}>Ranked Match</p>
<h1 style={{ fontSize: 'clamp(30px,5vw,52px)', fontWeight: 800, color: '#111', margin: 0, textAlign: 'center' }}>EQAO Grade {gradeNum}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap', justifyContent: 'center' }}>
<PlayerChip name={myName} avatar={myAvatar} reverse={false} />
<span style={{ fontSize: 18, fontWeight: 700, color: '#aaa', letterSpacing: 2 }}>vs</span>
<PlayerChip name={opponentName} avatar={opponentAvatar} reverse={true} />
</div>
<p style={{ fontSize: 14, color: '#aaa', margin: 0 }}>First to finish all {STAGE_SIZE} questions wins.</p>
<div style={{ fontSize: 'clamp(88px,18vw,144px)', fontWeight: 900, color: '#e53935', lineHeight: 1, fontVariantNumeric: 'tabular-nums', minWidth: 120, textAlign: 'center' }}>{count}</div>
</div>
);
}
function ProgressStrip({ total, current, answers, onJump }) {
const items = [];
for (let qi = 0; qi < total; qi++) {
const active = qi === current, answered = answers[qi] != null, filled = answered;
items.push(
<button key={`b-${qi}`} onClick={() => onJump && onJump(qi)} style={{ width: 36, height: 36, borderRadius: '50%', border: `2px solid ${filled || active ? '#1a73e8' : '#ccc'}`, background: filled ? '#1a73e8' : active ? '#e8f0fe' : '#fff', color: filled ? '#fff' : active ? '#1a73e8' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s' }}>{qi + 1}</button>
);
if (qi < total - 1) items.push(<div key={`l-${qi}`} style={{ flex: 1, height: 3, minWidth: 2, background: answers[qi] != null ? '#1a73e8' : '#ddd' }} />);
}
return <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%' }}>{items}</div>;
}
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName }) {
useEffect(() => {
const handler = (e) => {
const i = +e.key - 1;
if (i >= 0 && i < q.choices.length) { onSelect(i); return; }
if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext();
if (e.key === 'ArrowLeft' && qIdx > 0) onBack();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
return (
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', boxShadow: '0 0 40px rgba(0,60,120,0.10)' }}>
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '20px 32px 16px' }}>
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 14px 0', lineHeight: 1 }}>Question {qIdx + 1} of {STAGE_SIZE}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#1a73e8', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif' }}>You</span>
<div style={{ flex: 1 }}><ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} /></div>
</div>
<OpponentBar progress={opponentProgress} total={STAGE_SIZE} name={opponentName} />
</div>
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>{q.question}</p>
<div style={q.layout === 'grid' ? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } : { display: 'flex', flexDirection: 'column', gap: 12 }}>
{q.choices.map((choice, i) => {
const sel = selected === i;
return (
<button key={i} onClick={() => onSelect(i)} style={{ minHeight: 64, padding: '18px 24px', background: sel ? '#e8f0fe' : '#f7f8fa', border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`, borderRadius: 12, fontSize: 20, color: sel ? '#1a73e8' : '#222', textAlign: 'left', cursor: 'pointer', fontWeight: sel ? 600 : 400, fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45, transition: 'background 0.12s, border-color 0.12s' }}>
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>{i + 1}</span>{choice}
</button>
);
})}
</div>
</div>
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'flex-end', gap: 14 }}>
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}> Back</button>}
{qIdx === STAGE_SIZE - 1 ? (
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>Submit </button>
) : (
<button style={NAV_BTN} onClick={onNext}>Next </button>
)}
</div>
</div>
</div>
);
}
function DefeatOverlay({ opponentName, onViewResults }) {
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000 }}>
<div style={{ background: '#fff', borderRadius: 16, padding: '40px 48px', textAlign: 'center', maxWidth: 380, width: '90%', fontFamily: 'system-ui,sans-serif', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
<div style={{ fontSize: 64, marginBottom: 12 }}>💀</div>
<h1 style={{ fontSize: 32, fontWeight: 800, color: '#c62828', margin: '0 0 8px' }}>Defeated!</h1>
<p style={{ fontSize: 16, color: '#555', margin: '0 0 24px', lineHeight: 1.5 }}>{opponentName} finished before you.</p>
<button onClick={onViewResults} style={{ padding: '12px 36px', fontSize: 16, fontWeight: 600, background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>See Results</button>
</div>
</div>
);
}
function WaitingScreen({ opponentName, opponentProgress }) {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#ff69b4', animation: 'spin 0.6s linear infinite' }} />
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Waiting for {opponentName}...</h2>
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>You finished! Now waiting for your opponent to complete.</p>
<div style={{ width: 300, marginTop: 8 }}>
<OpponentBar progress={opponentProgress} total={STAGE_SIZE} name={opponentName} />
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
function CompleteScreen({ won, opponentName, duration, eloChange, onDashboard }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
<div style={{ fontSize: 72 }}>{won ? '🏆' : '💀'}</div>
<h1 style={{ fontSize: 36, fontWeight: 800, color: won ? '#1b8a2d' : '#c62828', margin: 0 }}>{won ? 'Victory!' : 'Defeated!'}</h1>
<p style={{ fontSize: 17, color: '#555', maxWidth: 400, margin: 0 }}>{won ? `You beat ${opponentName}! Well played.` : `${opponentName} beat you. Better luck next time.`}</p>
<div style={{ padding: '14px 40px', background: won ? '#f0faf0' : '#fef0f0', border: `2px solid ${won ? '#a5d6a7' : '#ef9a9a'}`, borderRadius: 12, fontSize: 24, fontWeight: 700, color: won ? '#1b8a2d' : '#c62828' }}>
{Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')}
</div>
{eloChange !== 0 && <div style={{ padding: '10px 24px', borderRadius: 8, fontSize: 18, fontWeight: 700, fontFamily: 'system-ui,sans-serif', color: eloChange > 0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}</div>}
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>Back to Dashboard</button>
</div>
</div>
);
}
function authHeaders() {
const token = sessionStorage.getItem('ranked_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
export default function Queue() {
const navigate = useNavigate();
const { state } = useLocation();
const grade = state?.grade ?? null;
const [myName, setMyName] = useState('');
const [myAvatar, setMyAvatar] = useState(null);
const [phase, setPhase] = useState('finding');
const [questions, setQuestions] = useState([]);
const [qIdx, setQIdx] = useState(0);
const [answers, setAnswers] = useState({});
const [selected, setSelected] = useState(null);
const [opponentProgress, setOpponentProgress] = useState(0);
const [opponentName, setOpponentName] = useState('');
const [opponentAvatar, setOpponentAvatar] = useState(null);
const [playerWon, setPlayerWon] = useState(null);
const [showDefeatOverlay, setShowDefeatOverlay] = useState(false);
const [startTime] = useState(() => Date.now());
const [duration, setDuration] = useState(0);
const [eloChange, setEloChange] = useState(0);
const [queueElapsed, setQueueElapsed] = useState(0);
const [showForceMatch, setShowForceMatch] = useState(false);
const gameIdRef = useRef(null);
const pollRef = useRef(null);
const completedRef = useRef(false);
const submittedRef = useRef(false);
const joinedRef = useRef(false);
const leaveTimerRef = useRef(null);
// Elapsed timer while finding
useEffect(() => {
if (phase !== 'finding') return;
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
return () => clearInterval(id);
}, [phase]);
// Show force match after 15s
useEffect(() => {
if (phase !== 'finding') return;
const id = setTimeout(() => setShowForceMatch(true), 15000);
return () => clearTimeout(id);
}, [phase]);
async function forceMatch() {
clearInterval(pollRef.current);
console.log('[queue] force match requested');
try {
const r = await fetch(`${API}/queue/debug`, { headers: authHeaders() });
const debug = await r.json();
console.log('[queue] debug state:', JSON.stringify(debug));
} catch {}
fetch(`${API}/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ grade }),
})
.then(r => {
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(data => {
console.log('[queue] force match result:', JSON.stringify(data));
if (!data) return;
joinedRef.current = true;
if (data.status === 'waiting') {
pollQueue();
} else if (data.status === 'matched') {
fetchMatchData(data.gameId);
}
})
.catch(() => {});
}
// Fetch own user info
useEffect(() => {
fetch(`${API}/auth/me`, { headers: authHeaders() })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
setMyName(data.username ?? data.displayName ?? 'You');
setMyAvatar(data.avatarUrl ?? null);
})
.catch(() => {});
}, []);
// Join queue on mount
useEffect(() => {
// Cancel any pending leave from a previous unmount (Strict Mode guard)
if (leaveTimerRef.current) {
clearTimeout(leaveTimerRef.current);
leaveTimerRef.current = null;
}
let cancelled = false;
console.log('[queue] joining with grade', grade);
fetch(`${API}/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ grade }),
})
.then(r => {
if (r.status === 401) { console.log('[queue] 401 on join'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(data => {
console.log('[queue] join response:', JSON.stringify(data));
if (!data || cancelled) return;
joinedRef.current = true;
if (data.status === 'waiting') {
pollQueue();
} else if (data.status === 'matched') {
fetchMatchData(data.gameId);
}
})
.catch(e => console.log('[queue] join error:', e));
return () => {
cancelled = true;
clearInterval(pollRef.current);
if (joinedRef.current && !gameIdRef.current && !completedRef.current) {
leaveTimerRef.current = setTimeout(() => {
fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {});
}, 300);
}
};
}, [grade, navigate]);
function pollQueue() {
pollRef.current = setInterval(() => {
fetch(`${API}/queue/status`, { headers: authHeaders() })
.then(r => {
if (r.status === 401) { console.log('[queue] 401 on poll'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(data => {
if (!data) return;
if (data.status === 'matched') {
console.log('[queue] poll found match!');
clearInterval(pollRef.current);
gameIdRef.current = data.gameId;
setQuestions(data.questions);
setOpponentName(data.opponent.username ?? data.opponent.displayName);
setOpponentAvatar(data.opponent.avatarUrl);
setPhase('intro');
} else if (data.status === 'not_in_queue') {
console.log('[queue] poll got not_in_queue, rejoining');
clearInterval(pollRef.current);
fetch(`${API}/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ grade }),
})
.then(r => {
if (r.status === 401) { console.log('[queue] 401 on rejoin'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(d => {
console.log('[queue] rejoin response:', JSON.stringify(d));
if (!d) return;
if (d.status === 'waiting') pollQueue();
else if (d.status === 'matched') fetchMatchData(d.gameId);
})
.catch(() => {});
}
})
.catch(() => {});
}, 1500);
}
function fetchMatchData(gameId) {
gameIdRef.current = gameId;
fetch(`${API}/queue/status`, { headers: authHeaders() })
.then(r => r.json())
.then(data => {
if (data.status === 'matched') {
setQuestions(data.questions);
setOpponentName(data.opponent.username ?? data.opponent.displayName);
setOpponentAvatar(data.opponent.avatarUrl);
setPhase('intro');
}
})
.catch(() => {});
}
function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) {
fetch(`${API}/game/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId: gameIdRef.current, currentQuestion, correctAnswers, finished, timeSpentMs }),
}).catch(() => {});
}
function pollGameStatus() {
const id = gameIdRef.current;
if (!id) return;
pollRef.current = setInterval(() => {
if (completedRef.current) { clearInterval(pollRef.current); return; }
fetch(`${API}/game/status/${id}`, { headers: authHeaders() })
.then(r => r.json())
.then(data => {
if (data.status === 'completed') {
clearInterval(pollRef.current);
const you = data.players.you;
const won = data.youWon === true;
completedRef.current = true;
setDuration(you.timeSpentMs || (Date.now() - startTime));
setEloChange(you.eloChange || 0);
setPlayerWon(won);
if (won) {
setPhase('complete');
} else {
setShowDefeatOverlay(true);
}
} else {
const opp = data.players.opponent;
if (opp.username) setOpponentName(opp.username);
setOpponentProgress(opp.currentQuestion + 1);
}
})
.catch(() => {});
}, 1000);
}
function handleNext() {
const newAnswers = { ...answers, [qIdx]: selected };
setAnswers(newAnswers);
const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length;
const elapsed = Date.now() - startTime;
if (qIdx < STAGE_SIZE - 1) {
submitProgress(qIdx + 1, correctCount, false);
const next = qIdx + 1;
setQIdx(next);
setSelected(newAnswers[next] ?? null);
} else {
submittedRef.current = true;
submitProgress(STAGE_SIZE, correctCount, true, elapsed);
setPhase('waiting');
}
}
function handleBack() {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
setAnswers(newAnswers);
const prev = qIdx - 1;
setQIdx(prev);
setSelected(newAnswers[prev] ?? null);
}
function handleJump(targetIdx) {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
setAnswers(newAnswers);
setQIdx(targetIdx);
setSelected(newAnswers[targetIdx] ?? null);
}
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
if (phase === 'finding')
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} showForceMatch={showForceMatch} onForceMatch={forceMatch} />;
if (phase === 'intro')
return <StageIntro grade={grade} myName={myName} myAvatar={myAvatar} opponentName={opponentName} opponentAvatar={opponentAvatar} onNext={() => { setPhase('question'); pollGameStatus(); }} />;
if (phase === 'question')
return (
<>
<QuestionScreen
question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected}
onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump}
opponentProgress={opponentProgress} opponentName={opponentName}
/>
{showDefeatOverlay && (
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
)}
</>
);
if (phase === 'waiting')
return (
<>
<WaitingScreen opponentName={opponentName} opponentProgress={opponentProgress} />
{showDefeatOverlay && (
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
)}
</>
);
if (phase === 'complete')
return (
<CompleteScreen
won={playerWon} opponentName={opponentName} duration={duration}
eloChange={eloChange} onDashboard={() => navigate('/dashboard')}
/>
);
return null;
}
+42
View File
@@ -0,0 +1,42 @@
import { useNavigate } from 'react-router-dom';
import { useTheme } from '../context/ThemeContext';
export default function Settings() {
const navigate = useNavigate();
const { theme, toggle } = useTheme();
return (
<>
<div className="arena-bg"><div className="arena-grain" /></div>
<nav className="dash-nav">
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
<button className="settings-back" onClick={() => navigate('/dashboard')}>Back</button>
</nav>
<main className="page settings-page">
<section className="settings-section">
<h1 className="settings-title">Settings</h1>
<div className="settings-card">
<div className="settings-row">
<div className="settings-row__info">
<span className="settings-row__label">Theme</span>
<span className="settings-row__desc">Switch between dark and light mode</span>
</div>
<button
className={`settings-toggle${theme === 'light' ? ' settings-toggle--on' : ''}`}
onClick={toggle}
role="switch"
aria-checked={theme === 'light'}
>
<span className="settings-toggle__thumb" />
</button>
</div>
</div>
</section>
<button className="settings-back-link" onClick={() => navigate('/dashboard')}>&larr; Dashboard</button>
</main>
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM"],
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});
+11
View File
@@ -0,0 +1,11 @@
Prisma DB: npx prisma studio
Run: cd frontend && npm run dev
Express server: node src/index.js
KILL 3000: kill $(lsof -ti:3000)
push: git -c http.proxy=socks5://127.0.0.1:9149 push origin main
pull: git -c http.proxy=socks5://127.0.0.1:9149 pull --rebase origin main
push at school: git push origin main
pull at school: git pull --rebase origin main
+1180
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "ranked-eqao",
"version": "1.0.0",
"description": "RANKED EQAO",
"main": "index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://git.cornlab.cc/john/ranked-eqao.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@prisma/client": "^5.22.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^4.22.1",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"prisma": "^5.22.0"
}
}
@@ -0,0 +1,102 @@
-- CreateEnum
CREATE TYPE "Subject" AS ENUM ('MATH', 'READING', 'WRITING');
-- CreateEnum
CREATE TYPE "Difficulty" AS ENUM ('EASY', 'MEDIUM', 'HARD');
-- CreateEnum
CREATE TYPE "SessionStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED', 'ABANDONED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"googleId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Question" (
"id" TEXT NOT NULL,
"grade" INTEGER NOT NULL,
"subject" "Subject" NOT NULL,
"strand" TEXT NOT NULL,
"difficulty" "Difficulty" NOT NULL,
"questionText" TEXT NOT NULL,
"options" JSONB NOT NULL,
"correctAnswer" TEXT NOT NULL,
"explanation" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Question_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GameSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"grade" INTEGER NOT NULL,
"subject" "Subject" NOT NULL,
"status" "SessionStatus" NOT NULL DEFAULT 'IN_PROGRESS',
"score" INTEGER NOT NULL DEFAULT 0,
"totalQuestions" INTEGER NOT NULL,
"correctAnswers" INTEGER NOT NULL DEFAULT 0,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
CONSTRAINT "GameSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserAnswer" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"questionId" TEXT NOT NULL,
"selectedAnswer" TEXT NOT NULL,
"isCorrect" BOOLEAN NOT NULL,
"timeSpentMs" INTEGER NOT NULL,
"answeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserAnswer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeaderboardEntry" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"grade" INTEGER NOT NULL,
"subject" "Subject" NOT NULL,
"bestScore" INTEGER NOT NULL,
"gamesPlayed" INTEGER NOT NULL,
"totalCorrect" INTEGER NOT NULL,
"rank" INTEGER,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeaderboardEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "LeaderboardEntry_userId_grade_subject_key" ON "LeaderboardEntry"("userId", "grade", "subject");
-- AddForeignKey
ALTER TABLE "GameSession" ADD CONSTRAINT "GameSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserAnswer" ADD CONSTRAINT "UserAnswer_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GameSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserAnswer" ADD CONSTRAINT "UserAnswer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LeaderboardEntry" ADD CONSTRAINT "LeaderboardEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
/*
Warnings:
- Changed the type of `grade` on the `GameSession` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Changed the type of `grade` on the `LeaderboardEntry` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Changed the type of `grade` on the `Question` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- CreateEnum
CREATE TYPE "Grade" AS ENUM ('G3', 'G6', 'G9');
-- AlterTable
ALTER TABLE "GameSession" DROP COLUMN "grade",
ADD COLUMN "grade" "Grade" NOT NULL;
-- AlterTable
ALTER TABLE "LeaderboardEntry" DROP COLUMN "grade",
ADD COLUMN "grade" "Grade" NOT NULL;
-- AlterTable
ALTER TABLE "Question" DROP COLUMN "grade",
ADD COLUMN "grade" "Grade" NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "LeaderboardEntry_userId_grade_subject_key" ON "LeaderboardEntry"("userId", "grade", "subject");
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "LeaderboardScore" (
"id" TEXT NOT NULL,
"playerName" TEXT NOT NULL,
"grade" "Grade" NOT NULL,
"score" INTEGER NOT NULL,
"totalQuestions" INTEGER NOT NULL,
"timeSeconds" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeaderboardScore_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "username" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
@@ -0,0 +1,26 @@
-- Drop stale table (old schema: subject, bestScore, rank — missing elo, bestTime)
DROP TABLE IF EXISTS "LeaderboardEntry";
-- Drop orphaned table from old migration
DROP TABLE IF EXISTS "LeaderboardScore";
-- Recreate with correct schema
CREATE TABLE "LeaderboardEntry" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"grade" "Grade" NOT NULL,
"elo" INTEGER NOT NULL DEFAULT 1000,
"bestTime" INTEGER,
"gamesPlayed" INTEGER NOT NULL DEFAULT 0,
"totalCorrect" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW(),
CONSTRAINT "LeaderboardEntry_pkey" PRIMARY KEY ("id")
);
-- Unique per user+grade (no subject)
CREATE UNIQUE INDEX "LeaderboardEntry_userId_grade_key" ON "LeaderboardEntry"("userId", "grade");
-- Foreign key
ALTER TABLE "LeaderboardEntry" ADD CONSTRAINT "LeaderboardEntry_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TABLE "LeaderboardEntry" ALTER COLUMN "elo" SET DEFAULT 800;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LeaderboardEntry" ALTER COLUMN "updatedAt" DROP DEFAULT;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+105
View File
@@ -0,0 +1,105 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Grade {
G3
G6
G9
}
enum Subject {
MATH
READING
WRITING
}
enum Difficulty {
EASY
MEDIUM
HARD
}
enum SessionStatus {
IN_PROGRESS
COMPLETED
ABANDONED
}
model User {
id String @id @default(cuid())
googleId String @unique
email String @unique
displayName String
username String? @unique
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions GameSession[]
leaderboardEntries LeaderboardEntry[]
}
model Question {
id String @id @default(cuid())
grade Grade
subject Subject
strand String
difficulty Difficulty
questionText String
options Json
correctAnswer String
explanation String?
createdAt DateTime @default(now())
answers UserAnswer[]
}
model GameSession {
id String @id @default(cuid())
userId String
grade Grade
subject Subject
status SessionStatus @default(IN_PROGRESS)
score Int @default(0)
totalQuestions Int
correctAnswers Int @default(0)
startedAt DateTime @default(now())
completedAt DateTime?
user User @relation(fields: [userId], references: [id])
answers UserAnswer[]
}
model UserAnswer {
id String @id @default(cuid())
sessionId String
questionId String
selectedAnswer String
isCorrect Boolean
timeSpentMs Int
answeredAt DateTime @default(now())
session GameSession @relation(fields: [sessionId], references: [id])
question Question @relation(fields: [questionId], references: [id])
}
model LeaderboardEntry {
id String @id @default(cuid())
userId String
grade Grade
elo Int @default(800)
bestTime Int?
gamesPlayed Int @default(0)
totalCorrect Int @default(0)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@unique([userId, grade])
}
BIN
View File
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
const passport = require('passport');
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const jwt = require('jsonwebtoken');
const db = require('../db');
async function generateUniqueUsername(base) {
const candidate = base.toLowerCase();
const taken = await db.user.findFirst({ where: { username: candidate } });
if (!taken) return candidate;
let attempts = 0;
while (attempts < 20) {
const suffix = String(Math.floor(1000 + Math.random() * 9000));
const username = `${candidate}${suffix}`;
const collision = await db.user.findFirst({ where: { username } });
if (!collision) return username;
attempts++;
}
return `${candidate}${Date.now()}`;
}
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
},
async (_accessToken, _refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value ?? '';
let user = await db.user.findUnique({ where: { googleId: profile.id } });
if (!user) {
const username = await generateUniqueUsername(email.split('@')[0]);
user = await db.user.create({
data: {
googleId: profile.id,
email,
displayName: profile.displayName,
avatarUrl: profile.photos?.[0]?.value ?? null,
username,
},
});
} else {
user = await db.user.update({
where: { googleId: profile.id },
data: {
email,
displayName: profile.displayName,
avatarUrl: profile.photos?.[0]?.value ?? null,
},
});
if (!user.username) {
const username = await generateUniqueUsername((user.email ?? '').split('@')[0]);
user = await db.user.update({ where: { id: user.id }, data: { username } });
}
}
const token = jwt.sign(
{
userId: user.id,
email: user.email,
name: user.displayName,
picture: user.avatarUrl,
},
process.env.JWT_SECRET,
{ algorithm: 'HS256', expiresIn: '7d' }
);
done(null, { token });
} catch (err) {
done(err);
}
}
)
);
module.exports = passport;
+5
View File
@@ -0,0 +1,5 @@
const { PrismaClient } = require('@prisma/client');
const db = new PrismaClient();
module.exports = db;
+559
View File
@@ -0,0 +1,559 @@
require('dotenv').config();
const crypto = require('crypto');
const express = require('express');
const cors = require('cors');
const passport = require('./auth/google');
const authMiddleware = require('./middleware/auth');
const db = require('./db');
const { getUserRank, upsertLeaderboardEntry } = require('./services/leaderboard');
const { calculateElo, DEFAULT_ELO } = require('./services/elo');
const { generateStage } = require('./services/questions');
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
const STAGE_SIZE = 11;
const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions
const DC_TIMEOUT_MS = 8000; // no heartbeat for this long → disconnected
const DC_GRACE_MS = 15000; // don't check until this long after game start
// ── In-memory matchmaking ──────────────────────────────────────
const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } }
const playerGames = {}; // { userId: gameId }
const activeGames = {}; // { gameId: { ... } }
function generateGameId() {
return crypto.randomBytes(8).toString('hex');
}
function computeGameResult(game) {
const players = Object.keys(game.players);
const p0 = game.players[players[0]];
const p1 = game.players[players[1]];
if (p0.finished && p1.finished) {
return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1];
}
if (p0.finished) return players[0];
if (p1.finished) return players[1];
return null;
}
async function completeGame(game, forcedWinnerId = null) {
if (game.completing || game.status !== 'active') return;
game.completing = true;
const players = Object.keys(game.players);
const winnerId = forcedWinnerId ?? computeGameResult(game);
game.winner = winnerId;
game.completedAt = Date.now();
for (const userId of players) {
const p = game.players[userId];
const won = userId === winnerId;
const currentEntry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId, grade: game.grade } },
});
const currentElo = currentEntry?.elo ?? DEFAULT_ELO;
const otherId = players.find(id => id !== userId);
const otherEntry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId: otherId, grade: game.grade } },
});
const opponentElo = otherEntry?.elo ?? DEFAULT_ELO;
const result = calculateElo(currentElo, opponentElo, won);
await upsertLeaderboardEntry(userId, game.grade, {
elo: result.newElo,
bestTime: p.timeSpentMs ?? undefined,
correctAnswers: p.correctAnswers,
});
p.eloChange = result.change;
p.newElo = result.newElo;
delete playerGames[userId];
}
// Set completed only after ELO is written so polls see final eloChange
game.status = 'completed';
setTimeout(() => {
delete activeGames[game.id];
}, 30000);
}
// Disconnect detection — runs every 3s
setInterval(() => {
const now = Date.now();
for (const game of Object.values(activeGames)) {
if (game.status !== 'active' || game.completing) continue;
if (now - game.startedAt < DC_GRACE_MS) continue;
const players = Object.keys(game.players);
const gone = players.filter(id => {
const p = game.players[id];
return !p.finished && now - (p.lastSeen ?? game.startedAt) > DC_TIMEOUT_MS;
});
if (gone.length === 2) {
// Both gone — cancel, no ELO change
game.completing = true;
game.status = 'completed';
game.winner = null;
game.completedAt = now;
for (const id of players) delete playerGames[id];
setTimeout(() => { delete activeGames[game.id]; }, 30000);
} else if (gone.length === 1) {
const winnerId = players.find(id => id !== gone[0]);
completeGame(game, winnerId);
}
}
}, 3000);
// Cleanup stale queue entries (5min timeout) and hard-expired games every 30s
setInterval(() => {
const now = Date.now();
for (const [userId, q] of Object.entries(queue)) {
if (now - q.joinedAt > 300000) delete queue[userId];
}
for (const [id, game] of Object.entries(activeGames)) {
if (game.status === 'active' && now - game.startedAt > 300000) {
const players = Object.keys(game.players);
const p0 = game.players[players[0]];
const p1 = game.players[players[1]];
if (p0.finished || p1.finished) {
completeGame(game);
} else {
game.completing = true;
game.status = 'completed';
game.winner = null;
game.completedAt = now;
for (const userId of players) delete playerGames[userId];
delete activeGames[id];
}
}
if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) {
delete activeGames[id];
}
}
}, 30000);
const app = express();
app.use(express.json());
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(passport.initialize());
app.get('/auth/google', passport.authenticate('google', {
scope: ['profile', 'email'],
session: false,
}));
app.get('/auth/google/callback', (req, res, next) => {
passport.authenticate('google', { session: false }, (err, result) => {
if (err || !result) {
console.error('[auth/google/callback] failure:', err);
return res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`);
}
res.redirect(`${process.env.FRONTEND_URL}/dashboard?token=${result.token}`);
})(req, res, next);
});
app.get('/auth/me', authMiddleware, async (req, res) => {
try {
const user = await db.user.findUnique({ where: { id: req.user.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
id: user.id,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
email: user.email,
username: user.username,
});
} catch (err) {
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/leaderboard', async (req, res) => {
const { grade } = req.query;
if (!VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
}
try {
const entries = await db.leaderboardEntry.findMany({
where: { grade, bestTime: { not: null } },
orderBy: { bestTime: 'asc' },
take: 10,
include: {
user: { select: { displayName: true, username: true, avatarUrl: true } },
},
});
res.json(entries.map((e, i) => ({
rank: i + 1,
displayName: e.user.displayName,
username: e.user.username,
avatarUrl: e.user.avatarUrl,
bestTime: e.bestTime,
})));
} catch {
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/me/stats', authMiddleware, async (req, res) => {
try {
const entries = await Promise.all(
['G3', 'G6', 'G9'].map(async grade => {
const [eloResult, timeResult] = await Promise.all([
getUserRank(req.user.userId, grade, 'elo'),
getUserRank(req.user.userId, grade, 'bestTime'),
]);
const entry = eloResult?.entry ?? null;
return {
grade,
elo: entry?.elo ?? 800,
bestTime: entry?.bestTime ?? null,
gamesPlayed: entry?.gamesPlayed ?? 0,
totalCorrect: entry?.totalCorrect ?? 0,
eloRank: eloResult?.rank ?? null,
timeRank: timeResult?.rank ?? null,
};
})
);
res.json({ entries });
} catch {
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/game/complete', authMiddleware, async (req, res) => {
const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body;
if (!VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
}
if (typeof correctAnswers !== 'number' || typeof totalQuestions !== 'number') {
return res.status(400).json({ error: 'correctAnswers and totalQuestions required' });
}
try {
const userId = req.user.userId;
const currentEntry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId, grade } },
});
await upsertLeaderboardEntry(userId, grade, {
bestTime: typeof timeSpentMs === 'number' ? Math.round(timeSpentMs / 1000) : undefined,
correctAnswers,
});
res.json({
elo: currentEntry?.elo ?? DEFAULT_ELO,
eloChange: 0,
bestTime: currentEntry?.bestTime ?? null,
gamesPlayed: (currentEntry?.gamesPlayed ?? 0) + 1,
totalCorrect: (currentEntry?.totalCorrect ?? 0) + correctAnswers,
});
} catch (err) {
console.error('[game/complete] error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.put('/me/username', authMiddleware, async (req, res) => {
const { username } = req.body;
if (!username || !USERNAME_RE.test(username)) {
return res.status(400).json({ error: 'invalid' });
}
const lower = username.toLowerCase();
try {
const conflict = await db.user.findFirst({
where: { username: lower, NOT: { id: req.user.userId } },
});
if (conflict) return res.status(409).json({ error: 'taken' });
const updated = await db.user.update({
where: { id: req.user.userId },
data: { username: lower },
});
res.json({ username: updated.username });
} catch {
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/questions', authMiddleware, (req, res) => {
const { grade } = req.query;
if (!VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
}
res.json(generateStage(grade, 1));
});
app.post('/session/start', authMiddleware, async (req, res) => {
const { grade, subject } = req.body;
const VALID_SUBJECTS = new Set(['MATH', 'READING', 'WRITING']);
if (!VALID_GRADES.has(grade) || !VALID_SUBJECTS.has(subject)) {
return res.status(400).json({ error: 'invalid grade or subject' });
}
try {
const session = await db.gameSession.create({
data: { userId: req.user.userId, grade, subject, totalQuestions: 22, status: 'IN_PROGRESS' },
});
res.json({ sessionId: session.id });
} catch (err) {
console.error('[session/start]', err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/session/complete', authMiddleware, async (req, res) => {
const { sessionId, correctAnswers, totalQuestions } = req.body;
if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
try {
const session = await db.gameSession.findUnique({ where: { id: sessionId } });
if (!session || session.userId !== req.user.userId) {
return res.status(404).json({ error: 'Session not found' });
}
const total = totalQuestions ?? session.totalQuestions;
await db.gameSession.update({
where: { id: sessionId },
data: {
status: 'COMPLETED',
correctAnswers: correctAnswers ?? 0,
totalQuestions: total,
score: Math.round(((correctAnswers ?? 0) / (total || 1)) * 100),
completedAt: new Date(),
},
});
res.json({ ok: true });
} catch (err) {
console.error('[session/complete]', err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/me/best-time', authMiddleware, async (req, res) => {
const { grade, timeMs } = req.body;
if (!VALID_GRADES.has(grade) || typeof timeMs !== 'number' || timeMs <= 0) {
return res.status(400).json({ error: 'Invalid grade or timeMs' });
}
try {
const entry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId: req.user.userId, grade } },
});
const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime;
const updated = await db.leaderboardEntry.upsert({
where: { userId_grade: { userId: req.user.userId, grade } },
create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 },
update: {
...(isNewBest ? { bestTime: timeMs } : {}),
gamesPlayed: { increment: 1 },
},
});
res.json({ updated: isNewBest, bestTime: updated.bestTime });
} catch {
res.status(500).json({ error: 'Internal server error' });
}
});
// ── Matchmaking ────────────────────────────────────────────────
app.post('/queue/join', authMiddleware, async (req, res) => {
const { grade } = req.body;
if (!VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
}
try {
if (playerGames[req.user.userId]) {
return res.json({ status: 'matched', gameId: playerGames[req.user.userId] });
}
if (queue[req.user.userId]) {
return res.json({ status: 'waiting' });
}
const user = await db.user.findUnique({ where: { id: req.user.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
console.log(`[queue/join] user=${req.user.userId} grade=${grade} queue_size=${Object.keys(queue).length} queue_keys=${JSON.stringify(Object.keys(queue))}`);
const existing = Object.entries(queue).find(([id, q]) => id !== req.user.userId && q.grade === grade);
if (existing) {
const [opponentId, opponentQueue] = existing;
delete queue[opponentId];
const gameGrade = opponentQueue.grade;
console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`);
const questions = generateStage(gameGrade, 1);
const gameId = generateGameId();
const game = {
id: gameId,
grade: gameGrade,
questions,
players: {
[opponentId]: { displayName: opponentQueue.displayName, username: opponentQueue.username, avatarUrl: opponentQueue.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
[req.user.userId]: { displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
},
status: 'active',
winner: null,
startedAt: Date.now(),
completedAt: null,
};
activeGames[gameId] = game;
playerGames[opponentId] = gameId;
playerGames[req.user.userId] = gameId;
return res.json({ status: 'matched', gameId });
}
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl };
res.json({ status: 'waiting' });
} catch (err) {
console.error('[queue/join] error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.delete('/queue/leave', authMiddleware, (req, res) => {
delete queue[req.user.userId];
res.json({ success: true });
});
app.get('/queue/debug', authMiddleware, (_req, res) => {
res.json({
queueSize: Object.keys(queue).length,
queue: Object.entries(queue).map(([id, q]) => ({ id: id.slice(0,8), grade: q.grade, joinedAgo: Date.now() - q.joinedAt })),
activeGames: Object.keys(activeGames).length,
playerGames: Object.keys(playerGames).length,
});
});
app.get('/queue/status', authMiddleware, async (req, res) => {
const gameId = playerGames[req.user.userId];
if (gameId) {
const game = activeGames[gameId];
if (!game) {
delete playerGames[req.user.userId];
return res.json({ status: 'not_in_queue' });
}
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
const opponent = game.players[opponentId];
if (!opponent) {
delete playerGames[req.user.userId];
delete activeGames[gameId];
return res.json({ status: 'not_in_queue' });
}
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true, avatarUrl: true } });
return res.json({
status: 'matched',
gameId,
questions: game.questions,
opponent: {
username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName,
avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl,
},
});
}
if (queue[req.user.userId]) {
return res.json({ status: 'waiting' });
}
res.json({ status: 'not_in_queue' });
});
// ── Game management ────────────────────────────────────────────
app.post('/game/progress', authMiddleware, (req, res) => {
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
const game = activeGames[gameId];
if (!game || game.status !== 'active') {
return res.status(404).json({ error: 'Game not found or already completed' });
}
const player = game.players[req.user.userId];
if (!player) {
return res.status(403).json({ error: 'Not a participant in this game' });
}
if (player.finished) return res.json({ success: true }); // idempotent
player.lastSeen = Date.now();
if (currentQuestion !== undefined) {
player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion))));
}
if (correctAnswers !== undefined) {
player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers))));
}
if (finished) {
const elapsed = Date.now() - game.startedAt;
const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed;
// Clamp: no faster than minimum human time, no slower than actual elapsed + small buffer
player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000));
player.finished = true;
player.finishedAt = Date.now();
const winner = computeGameResult(game);
if (winner) completeGame(game);
}
res.json({ success: true });
});
app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
const game = activeGames[req.params.gameId];
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
const player = game.players[req.user.userId];
if (!player) {
return res.status(403).json({ error: 'Not a participant in this game' });
}
player.lastSeen = Date.now();
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
const opponent = game.players[opponentId];
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
res.json({
status: game.status,
youWon: game.winner !== null && game.winner === req.user.userId,
players: {
you: {
currentQuestion: player.currentQuestion,
correctAnswers: player.correctAnswers,
finished: player.finished,
timeSpentMs: player.timeSpentMs,
eloChange: player.eloChange,
newElo: player.newElo,
},
opponent: {
username: opponentName,
currentQuestion: opponent.currentQuestion,
correctAnswers: opponent.correctAnswers,
finished: opponent.finished,
timeSpentMs: opponent.timeSpentMs,
},
},
});
});
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
+19
View File
@@ -0,0 +1,19 @@
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization token' });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
req.user = { userId: payload.userId, email: payload.email };
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = authMiddleware;
+16
View File
@@ -0,0 +1,16 @@
const DEFAULT_ELO = 800;
const BASE_CHANGE = 10;
const DIFF_STEP = 30;
const MAX_CHANGE = 30;
const MIN_CHANGE = 1;
// Custom ELO: base ±10, adjusted ±1 for every 30-point gap, clamped [1, 30].
// Underdog who wins gains more; favourite who wins gains less.
function calculateElo(yourElo, opponentElo, won) {
const diff = opponentElo - yourElo;
const magnitude = Math.max(MIN_CHANGE, Math.min(MAX_CHANGE, BASE_CHANGE + Math.floor(diff / DIFF_STEP)));
const change = won ? magnitude : -magnitude;
return { newElo: yourElo + change, change };
}
module.exports = { calculateElo, DEFAULT_ELO };
+73
View File
@@ -0,0 +1,73 @@
const db = require('../db');
async function upsertLeaderboardEntry(userId, grade, { elo, bestTime, correctAnswers = 0 } = {}) {
const existing = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId, grade } },
});
return db.leaderboardEntry.upsert({
where: { userId_grade: { userId, grade } },
create: {
userId,
grade,
elo: elo ?? 800,
bestTime: bestTime ?? null,
gamesPlayed: 1,
totalCorrect: correctAnswers,
},
update: {
...(elo !== undefined && { elo }),
...(bestTime !== undefined && existing?.bestTime == null || bestTime < (existing?.bestTime ?? Infinity)
? { bestTime }
: {}),
gamesPlayed: { increment: 1 },
totalCorrect: { increment: correctAnswers },
},
});
}
async function getLeaderboard(grade, sortBy = 'elo', limit = 10) {
const orderBy = sortBy === 'bestTime'
? { bestTime: { sort: 'asc', nulls: 'last' } }
: { elo: 'desc' };
const entries = await db.leaderboardEntry.findMany({
where: { grade },
orderBy,
take: limit,
include: {
user: { select: { displayName: true, avatarUrl: true } },
},
});
return entries.map((entry, index) => ({ ...entry, rank: index + 1 }));
}
async function getUserRank(userId, grade, sortBy = 'elo') {
const entry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId, grade } },
});
if (!entry) return null;
if (sortBy === 'bestTime') {
if (entry.bestTime === null) return { rank: null, entry };
const count = await db.leaderboardEntry.count({
where: {
grade,
AND: [
{ bestTime: { not: null } },
{ bestTime: { lt: entry.bestTime } },
],
},
});
return { rank: count + 1, entry };
}
const count = await db.leaderboardEntry.count({
where: { grade, elo: { gt: entry.elo } },
});
return { rank: count + 1, entry };
}
module.exports = { upsertLeaderboardEntry, getLeaderboard, getUserRank };
+110
View File
@@ -0,0 +1,110 @@
const rand = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a;
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const shuffle = arr => {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
};
function makeQ(question, correct, wrongs) {
const ca = String(correct);
const ws = [...new Set(wrongs.map(String))].filter(w => w !== ca);
const choices = shuffle([ca, ...ws]);
return { question, choices, correct: choices.indexOf(ca), layout: choices.length === 3 ? 'list' : 'grid' };
}
const g3s1 = [
() => { const p1 = rand(15,85), p2 = rand(10,75), t = p1+p2; const item1 = pick(['a pencil','an eraser','a ruler','a bookmark']), item2 = pick(['a sticker','a crayon','a marker','a folder']); return makeQ(`Sasha buys ${item1} for ${p1}¢ and ${item2} for ${p2}¢. How much does she pay altogether?`, `${t}¢`, [`${t+rand(5,15)}¢`,`${t-rand(3,10)}¢`,`${t+rand(20,35)}¢`]); },
() => { const w = rand(2,6), t = w*7; return makeQ(`How many days are in ${w} weeks?`, t, [t+7, t-7, t+rand(1,3)]); },
() => { const step = pick([2,5,10,3]), start = rand(1,8)*step, seq = [start,start+step,start+2*step,start+3*step], next = start+4*step; return makeQ(`What is the next number in this pattern?\n${seq.join(', ')}, ____`, next, [next+step, next-step, next+2*step]); },
() => { const l = rand(3,12), w2 = rand(2,9), p = 2*(l+w2); return makeQ(`A rectangle is ${l} cm long and ${w2} cm wide. What is its perimeter?`, `${p} cm`, [`${p+4} cm`,`${p-4} cm`,`${l*w2} cm`]); },
() => { const d = pick([2,3,4,6,8]), n = rand(1,d-1); return makeQ(`A pizza is cut into ${d} equal slices. ${n} slice${n>1?'s are':' is'} eaten. What fraction has been eaten?`, `${n}/${d}`, [`${n+1}/${d}`,`${n}/${d+1}`,`${d-n}/${d}`]); },
() => { const g = rand(3,8), e = rand(2,9), t = g*e; const item = pick(['apples','books','stickers','marbles']); return makeQ(`There are ${g} bags with ${e} ${item} in each bag. How many ${item} are there in total?`, t, [t+e, t-e, g+e]); },
() => { const [obj,unit,w1,w2,w3] = pick([['the length of a room','metres','centimetres','kilometres','millimetres'],['the weight of a watermelon','kilograms','grams','litres','metres'],['the length of an ant','millimetres','metres','kilometres','kilograms'],['the amount of water in a bathtub','litres','kilograms','metres','grams']]); return makeQ(`Which unit would you use to measure ${obj}?`, unit, [w1,w2,w3]); },
() => { const sh = rand(8,11), dur = rand(1,4), eh = sh+dur; const item = pick(['movie','soccer game','music class','swimming lesson']); return makeQ(`A ${item} starts at ${sh}:00 and ends at ${eh}:00. How long does it last?`, `${dur} hour${dur>1?'s':''}`, [`${dur+1} hours`,`${dur>1?dur-1:dur+2} hours`,`${dur+2} hours`]); },
() => { const total = rand(40,120), used = rand(10,total-10), left = total-used; const item = pick(['stickers','crayons','cards','beads']); return makeQ(`Jamal had ${total} ${item}. He gave ${used} to his friend. How many does he have now?`, left, [left+rand(5,10), left>5?left-rand(3,5):left+15, total+used]); },
() => { const n = rand(10,99); const isEven = n%2===0; return makeQ(`Is the number ${n} even or odd?`, isEven?'Even':'Odd', isEven?['Odd','Neither','Both']:['Even','Neither','Both']); },
() => { const animals=['dogs','cats','birds'], counts=[rand(3,9),rand(3,9),rand(3,9)], total=counts.reduce((a,b)=>a+b,0), table=animals.map((a,i)=>`${a}: ${counts[i]}`).join(' | '); return makeQ(`A survey counted pets:\n${table}\nHow many pets were counted in total?`, total, [total+rand(2,8), total-rand(2,5), counts[0]+counts[1]]); },
];
const g3s2 = [
() => { const l = rand(3,7), w2 = rand(2,6), area = l*w2; return makeQ(`A rectangle is ${l} units long and ${w2} units wide. What is its area?`, `${area} square units`, [`${area+l} square units`,`${2*(l+w2)} square units`,`${area-w2} square units`]); },
() => { const start = rand(2,15), rule = rand(3,9), seq = [start,start+rule,start+2*rule,start+3*rule]; return makeQ(`This pattern follows a rule: ${seq.join(', ')} ...\nWhat is the rule?`, `Add ${rule}`, [`Add ${rule+1}`,`Add ${rule-1}`,`Multiply by 2`]); },
() => { const b = pick([4,6,8]); const a = rand(1,b-1); const d = pick([4,6,8]); let c = rand(1,d-1); let tries=0; while(a*d===c*b && tries<20){c=rand(1,d-1);tries++;} const bigger = a/b>c/d?`${a}/${b}`:`${c}/${d}`, smaller = a/b>c/d?`${c}/${d}`:`${a}/${b}`; return makeQ(`Which fraction is larger: ${a}/${b} or ${c}/${d}?`, bigger, [smaller,'They are equal','Cannot tell']); },
() => { const [shape,faces] = pick([['cube',6],['rectangular prism',6],['triangular prism',5],['square pyramid',5]]); return makeQ(`How many faces does a ${shape} have?`, faces, [faces+1, faces-1, faces+2]); },
() => { const a = rand(3,9), b = rand(3,9), product = a*b; return makeQ(`What is ${a} × ${b}?`, product, [product+b, product-a, (a+1)*b]); },
() => { const price = rand(25,95), paid = Math.ceil(price/25)*25+25, change = paid-price; return makeQ(`An item costs ${price}¢. You pay ${paid}¢. How much change do you get?`, `${change}¢`, [`${change+5}¢`,`${change-5}¢`,`${change+10}¢`]); },
() => { const sh = rand(9,12), m = pick([0,30]), dur = rand(1,3), eh = sh+dur; const fmt = (h,mm) => `${h}:${mm===0?'00':mm}`; return makeQ(`A library visit starts at ${fmt(sh,m)} and lasts ${dur} hour${dur>1?'s':''}. What time does it end?`, fmt(eh,m), [fmt(eh+1,m),fmt(eh-1,m),fmt(eh,m===0?30:0)]); },
() => { const rows = rand(3,6), cols = rand(3,6), extra = rand(2,8), total = rows*cols+extra; const item = pick(['chairs','books','tiles','eggs']); return makeQ(`There are ${rows} rows of ${cols} ${item} and ${extra} more on the side. How many ${item} altogether?`, total, [rows*cols, total+extra, total-rows]); },
() => { const n = rand(12,97), rounded = Math.round(n/10)*10; return makeQ(`Round ${n} to the nearest 10.`, rounded, [rounded+10, rounded-10>=0?rounded-10:rounded+20, n]); },
() => { const n = rand(2,10)*4, quarter = n/4; return makeQ(`What is one quarter of ${n}?`, quarter, [n/2, quarter*3, quarter+1]); },
() => { const a = rand(100,499), b = rand(100,499), total = a+b; return makeQ(`What is ${a} + ${b}?`, total, [total+10, total-10, total+100]); },
];
const g6s1 = [
() => { const rate = rand(3,12), time = rand(4,10), total = rate*time; const item = pick(['pages','laps','problems','push-ups']); return makeQ(`Priya reads ${rate} ${item} per hour. How many will she read in ${time} hours?`, total, [total+rate, total-rate, rate+time]); },
() => { const pct = pick([10,20,25,50]), whole = rand(2,20)*10, result = whole*pct/100; return makeQ(`What is ${pct}% of ${whole}?`, result, [result+5, result*2, whole-result]); },
() => { const base = rand(4,14)*2, height = rand(3,10)*2, area = (base*height)/2; return makeQ(`A triangle has a base of ${base} cm and a height of ${height} cm. What is its area?`, `${area} cm²`, [`${base*height} cm²`,`${area+base} cm²`,`${area-height} cm²`]); },
() => { const bp = rand(2,5), rp = rand(2,5), mult = rand(2,5), blue = bp*mult, red = rp*mult; return makeQ(`For every ${bp} blue marbles there are ${rp} red marbles. If there are ${blue} blue marbles, how many red marbles are there?`, red, [red+rp, red-rp, red+mult]); },
() => { const a = rand(2,8), b = rand(2,6), c = rand(2,10), result = a+b*c; return makeQ(`Evaluate: ${a} + ${b} × ${c}`, result, [(a+b)*c, result+b, result-a]); },
() => { const l = rand(2,8), w2 = rand(2,6), h = rand(2,5), vol = l*w2*h; return makeQ(`What is the volume of a rectangular prism with length ${l} cm, width ${w2} cm, and height ${h} cm?`, `${vol} cm³`, [`${vol+l*w2} cm³`,`${2*(l*w2+l*h+w2*h)} cm³`,`${l+w2+h} cm³`]); },
() => { const d = pick([2,3,4,5]), whole = d*rand(3,8), result = whole/d; return makeQ(`What is 1/${d} of ${whole}?`, result, [result+d, result*d, result-1]); },
() => { const a = rand(-8,-1), b = rand(1,10), sum = a+b; return makeQ(`What is ${a} + ${b}?`, sum, [sum+2, sum-2, Math.abs(a)+b]); },
() => { const vals = [rand(4,12),rand(4,12),rand(4,12),rand(4,12)]; const s = vals.reduce((a,b)=>a+b,0); const adj = s%4; if(adj!==0) vals[3]+=4-adj; const s2 = vals.reduce((a,b)=>a+b,0), mean = s2/4; return makeQ(`Find the mean of: ${vals.join(', ')}`, mean, [mean+2, mean-1, s2]); },
() => { const side = rand(3,15), perim = 4*side; return makeQ(`A square has a perimeter of ${perim} cm. What is the length of one side?`, `${side} cm`, [`${side+2} cm`,`${side-1} cm`,`${perim/2} cm`]); },
() => { const a = rand(1,9)+rand(1,9)/10, b = rand(1,9)+rand(1,9)/10, total = Math.round((a+b)*10)/10; return makeQ(`What is ${a.toFixed(1)} + ${b.toFixed(1)}?`, total.toFixed(1), [(total+0.5).toFixed(1),(total-0.3).toFixed(1),(total+1).toFixed(1)]); },
];
const g6s2 = [
() => { const total = rand(4,20)*10, pct = pick([10,20,25,50]), part = total*pct/100; return makeQ(`In a class of ${total} students, ${pct}% walk to school. How many students walk?`, part, [part+5, part-5, total-part]); },
() => { const km = rand(2,15), m = km*1000; return makeQ(`A hiking trail is ${km} km long. How many metres is this?`, `${m} m`, [`${m+100} m`,`${km*100} m`,`${m-500} m`]); },
() => { const x = rand(3,15), mult = rand(2,6), product = mult*x; return makeQ(`Solve for n: ${mult}n = ${product}`, x, [x+1, x-1, mult+x]); },
() => { const total = pick([4,5,6,8,10]), fav = rand(1,total-1); return makeQ(`A bag has ${total} marbles: ${fav} red and ${total-fav} blue. What is the probability of picking red?`, `${fav}/${total}`, [`${fav+1}/${total}`,`${total-fav}/${total}`,`${fav}/${total-1}`]); },
() => { const deg = pick([30,45,60,90,120,150]); const type = deg<90?'acute':deg===90?'right':'obtuse'; return makeQ(`An angle measures ${deg}°. What type of angle is it?`, type, ['acute','right','obtuse','reflex'].filter(t=>t!==type).slice(0,3)); },
() => { const boys = rand(3,8), girls = rand(3,8), total = boys+girls; return makeQ(`The ratio of boys to girls is ${boys}:${girls}. What fraction of the group are boys?`, `${boys}/${total}`, [`${boys}/${girls}`,`${girls}/${total}`,`${boys+1}/${total}`]); },
() => { const base = pick([2,3,4,5]), exp = pick([2,3]), result = Math.pow(base,exp); return makeQ(`What is ${base}^${exp}?`, result, [base*exp, result+base, result-base]); },
() => { const d = pick([4,6,8,12]); const n1 = rand(Math.floor(d/2)+1,d-1), n2 = rand(1,Math.floor(d/2)), diff = n1-n2; const gcd = (a,b) => b===0?a:gcd(b,a%b); const g = gcd(diff,d); const ans = `${diff/g}/${d/g}`; return makeQ(`What is ${n1}/${d} ${n2}/${d}? (simplify if possible)`, ans, [`${diff+1}/${d}`,`${n1}/${d}`,`${diff}/${d+2}`]); },
() => { const r = rand(3,10), circ = Math.round(2*3.14*r); return makeQ(`A circle has radius ${r} cm. What is its approximate circumference? (π ≈ 3.14)`, `${circ} cm`, [`${Math.round(3.14*r*r)} cm`,`${circ+6} cm`,`${Math.round(2*3.14*(r+1))} cm`]); },
() => { const rise = rand(1,5), run = rand(1,5); return makeQ(`A line rises ${rise} units for every ${run} units to the right. What is the slope?`, `${rise}/${run}`, [`${run}/${rise}`,`${rise+1}/${run}`,`${rise}/${run+1}`]); },
() => { const original = rand(4,20)*10, discount = pick([10,20,25]), saved = original*discount/100, final = original-saved; return makeQ(`A jacket costs $${original}. It is ${discount}% off. What is the sale price?`, `$${final}`, [`$${saved}`,`$${final+10}`,`$${final-5}`]); },
];
const g9s1 = [
() => { const x = rand(2,12), a = rand(2,7), b = rand(1,20), lhs = a*x+b; return makeQ(`Solve for x: ${a}x + ${b} = ${lhs}`, `x = ${x}`, [`x = ${x+1}`,`x = ${x-1}`,`x = ${lhs}`]); },
() => { const [a,b,c] = pick([[3,4,5],[5,12,13],[8,15,17],[6,8,10]]); return makeQ(`A right triangle has legs of ${a} and ${b}. What is the hypotenuse?`, c, [c+1, c-1, a+b]); },
() => { const x1=rand(0,4),y1=rand(0,4),rise=rand(1,5),run=rand(1,5); return makeQ(`Find the slope of the line through (${x1}, ${y1}) and (${x1+run}, ${y1+rise}).`, `${rise}/${run}`, [`${run}/${rise}`,`${rise+1}/${run}`,`-${rise}/${run}`]); },
() => { const a = rand(2,6), b = rand(1,8); return makeQ(`Expand: ${a}(x + ${b})`, `${a}x + ${a*b}`, [`${a}x + ${b}`,`${a+b}x`,`${a}x + ${a*b+1}`]); },
() => { const original = rand(4,20)*10, pct = pick([10,15,20,25,30]), increase = original*pct/100, final = original+increase; return makeQ(`A price of $${original} increases by ${pct}%. What is the new price?`, `$${final}`, [`$${final+10}`,`$${original-increase}`,`$${final-5}`]); },
() => { const a = rand(4,10), b = rand(6,14), h = rand(3,8), area = (a+b)*h/2; return makeQ(`A trapezoid has parallel sides of ${a} cm and ${b} cm, and height ${h} cm. What is its area?`, `${area} cm²`, [`${a*b*h/2} cm²`,`${area+h} cm²`,`${(a+b)*h} cm²`]); },
() => { const base = pick([2,3,5]), e1 = rand(2,5), e2 = rand(2,4); return makeQ(`Simplify: ${base}^${e1} × ${base}^${e2}`, `${base}^${e1+e2}`, [`${base}^${e1*e2}`,`${base}^${e1+e2+1}`,`${base*2}^${e1+e2}`]); },
() => { const m = rand(2,5), b = rand(1,8), x = rand(3,8), y = m*x+b; return makeQ(`If y = ${m}x + ${b}, what is y when x = ${x}?`, y, [y+m, y-m, m*x]); },
() => { const x = rand(3,12), a = rand(2,5), b = rand(1,15), rhs = a*x-b; return makeQ(`Solve for x: ${a}x ${b} = ${rhs}`, `x = ${x}`, [`x = ${x+1}`,`x = ${x-1}`,`x = ${rhs}`]); },
() => { const s = rand(3,8), sa = 6*s*s; return makeQ(`A cube has side length ${s} cm. What is its surface area?`, `${sa} cm²`, [`${s*s*s} cm³`,`${sa+s*s} cm²`,`${4*s*s} cm²`]); },
() => { const die = rand(1,5); const coin = pick(['heads','tails']); return makeQ(`A coin is flipped and a 6-sided die is rolled. What is the probability of getting ${coin} and rolling less than ${die+1}?`, `${die}/12`, [`${die}/6`,`1/${die+1}`,`${die+1}/12`]); },
];
const g9s2 = [
() => { const r = rand(2,6), s = rand(2,5), bc = r+s, cc = r*s; return makeQ(`Factor: x² + ${bc}x + ${cc}`, `(x + ${r})(x + ${s})`, [`(x + ${bc})(x + 1)`,`(x + ${r+1})(x + ${s-1})`,`(x ${r})(x ${s})`]); },
() => { const p = rand(5,20)*100, rate = pick([5,10,20]), interest = p*rate/100, total = p+interest; return makeQ(`$${p} is invested at ${rate}% simple interest for 1 year. What is the total after 1 year?`, `$${total}`, [`$${interest}`,`$${total+50}`,`$${p*2}`]); },
() => { const m = rand(1,4), b = rand(-5,8); const bs = b>=0?`+ ${b}`:` ${Math.abs(b)}`; return makeQ(`What is the y-intercept of y = ${m}x ${bs}?`, `(0, ${b})`, [`(${b}, 0)`,`(0, ${b+1})`,`(${m}, 0)`]); },
() => { const [a,bc,c] = pick([[3,4,5],[5,12,13],[8,15,17]]); return makeQ(`A right triangle has hypotenuse ${c} and one leg ${a}. What is the other leg?`, bc, [bc+1, bc-1, Math.round(Math.sqrt(c*c+a*a))]); },
() => { const x = rand(3,10), a = rand(2,5), b = rand(1,10), lhs = a*x+b; return makeQ(`Which value of x satisfies ${a}x + ${b} > ${lhs-1}?`, `x = ${x}`, [`x = ${x-1}`,`x = ${x-2}`,`x = 0`]); },
() => { const rate = rand(3,8), t1 = rand(2,5), t2 = t1+rand(1,3); return makeQ(`A cyclist travels ${rate*t1} km in ${t1} hours at constant speed. How far in ${t2} hours?`, `${rate*t2} km`, [`${rate*t2+rate} km`,`${rate*t1+t2} km`,`${rate*t2-rate} km`]); },
() => { const angle = rand(30,80); return makeQ(`Two parallel lines are cut by a transversal. One angle is ${angle}°. What is its co-interior angle?`, `${180-angle}°`, [`${angle}°`,`${180+angle}°`,`${90-angle}°`]); },
() => { const coeff = rand(1,9), exp2 = rand(3,5), value = coeff*Math.pow(10,exp2); return makeQ(`Which equals ${coeff} × 10^${exp2}?`, String(value), [String(value*10),String(value/10),String(coeff+exp2)]); },
() => { const r = rand(3,7), h = rand(4,10), vol = Math.round(3.14*r*r*h); return makeQ(`A cylinder has radius ${r} cm and height ${h} cm. Approximate its volume (π ≈ 3.14).`, `${vol} cm³`, [`${Math.round(3.14*r*h)} cm³`,`${Math.round(3.14*2*r*h)} cm³`,`${Math.round(3.14*r*r)} cm³`]); },
() => { const [trend,desc] = pick([['positive','as x increases, y increases'],['negative','as x increases, y decreases'],['no correlation','x and y show no pattern']]); return makeQ(`A scatter plot shows: ${desc}. What type of correlation is this?`, trend, ['positive','negative','no correlation'].filter(t=>t!==trend)); },
() => { const a = rand(1,3), b = rand(1,5), x = rand(2,5), result = a*x*x+b*x; return makeQ(`Evaluate ${a}x² + ${b}x when x = ${x}.`, result, [result+b, result-a, a*x+b]); },
];
const QUESTION_BANK = { G3: [...g3s1, ...g3s2], G6: [...g6s1, ...g6s2], G9: [...g9s1, ...g9s2] };
const STAGE_SIZE = 11;
function generateStage(grade, stageNum) {
return QUESTION_BANK[grade].slice((stageNum - 1) * STAGE_SIZE, stageNum * STAGE_SIZE).map(fn => fn());
}
module.exports = { generateStage, STAGE_SIZE };