Compare commits

...

3 Commits

Author SHA1 Message Date
Maurice 4b89a3b034 updated notes.txt 2026-07-11 11:28:36 -04:00
Maurice 045048db20 deleted node_modules 2026-07-11 11:28:16 -04:00
Maurice 1f99268588 fixed so the code compiles 2026-07-11 11:27:43 -04:00
20 changed files with 1990 additions and 817 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
+2
View File
@@ -4,6 +4,7 @@ import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Game from './pages/Game'; import Game from './pages/Game';
import Queue from './pages/Queue'; import Queue from './pages/Queue';
import RankedGame from './pages/RankedGame';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
function getValidStoredToken() { function getValidStoredToken() {
@@ -38,6 +39,7 @@ export default function App() {
<Route path="/dashboard" element={(loggedIn || hasUrlToken()) ? <Dashboard /> : <Navigate to="/" replace />} /> <Route path="/dashboard" element={(loggedIn || hasUrlToken()) ? <Dashboard /> : <Navigate to="/" replace />} />
<Route path="/game" element={(loggedIn || hasUrlToken()) ? <Game />: <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="/queue" element={(loggedIn || hasUrlToken()) ? <Queue /> : <Navigate to="/" replace />} />
<Route path="/ranked-game" element={(loggedIn || hasUrlToken()) ? <RankedGame /> : <Navigate to="/" replace />} />
<Route path="/settings" element={(loggedIn || hasUrlToken()) ? <Settings /> : <Navigate to="/" replace />} /> <Route path="/settings" element={(loggedIn || hasUrlToken()) ? <Settings /> : <Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react';
const API = import.meta.env.VITE_API_URL;
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
export default 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;
if (!USERNAME_RE.test(trimmed)) {
setError('3\u201320 characters, letters, numbers, and underscores only.');
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 \u2014 try another.');
else setError('Something went wrong. Try again.');
} 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\u2026' : 'Save'}
</button>
</div>
</div>
</div>
);
}
+690 -44
View File
@@ -20,9 +20,9 @@
--silver: #94a3b8; --silver: #94a3b8;
--platinum: #f5c518; --platinum: #f5c518;
--text: #e8eaf6; --text: #eceef8;
--text-muted: #4b5270; --text-muted: #7a85a8;
--text-dim: #6b7491; --text-dim: #9ba5c4;
--font-display: 'Russo One', sans-serif; --font-display: 'Russo One', sans-serif;
--font-ui: 'Barlow Condensed', sans-serif; --font-ui: 'Barlow Condensed', sans-serif;
@@ -595,16 +595,551 @@ a { color: inherit; text-decoration: none; }
/* Page wrapper */ /* Page wrapper */
.dash-page { .dash-page {
align-items: center; align-items: center;
padding: 80px 24px 80px; padding: 88px 24px 80px;
gap: 0;
}
/* Main content wrapper */
.db-wrap {
width: 100%;
max-width: 820px;
display: flex;
flex-direction: column;
gap: 0; gap: 0;
} }
/* Hero */ /* Hero */
.db-hero {
text-align: center;
padding: 44px 0 24px;
display: flex;
flex-direction: column;
align-items: center;
}
.db-hero__name {
font-family: var(--font-display);
font-size: clamp(44px, 9vw, 72px);
line-height: 1;
text-transform: uppercase;
color: var(--text);
text-shadow: 0 0 60px rgba(245,197,24,0.1);
}
/* ── CTA buttons ── */
.db-cta {
display: flex;
gap: 14px;
margin-bottom: 32px;
}
.db-btn {
flex: 1;
padding: 17px 24px;
border: none;
border-radius: var(--radius);
font-family: var(--font-display);
font-size: 18px;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.2s;
}
.db-btn--gold {
background: var(--gold);
color: #07080f;
box-shadow: 0 0 28px rgba(245,197,24,0.3), 0 4px 14px rgba(0,0,0,0.4);
}
.db-btn--gold:hover {
transform: translateY(-2px);
box-shadow: 0 0 44px rgba(245,197,24,0.45), 0 8px 20px rgba(0,0,0,0.5);
}
.db-btn--blue {
background: transparent;
color: var(--blue-bright);
border: 1.5px solid var(--blue);
box-shadow: 0 0 20px rgba(59,130,246,0.15), 0 4px 14px rgba(0,0,0,0.3);
}
.db-btn--blue:hover {
transform: translateY(-2px);
box-shadow: 0 0 36px rgba(59,130,246,0.28), 0 8px 20px rgba(0,0,0,0.4);
}
.db-btn:active { transform: translateY(0); }
/* ── Stats strip ── */
.db-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 36px;
}
.db-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 20px 12px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
position: relative;
overflow: hidden;
}
.db-stat::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(to right, transparent, var(--border-glow), transparent);
}
.db-stat__val {
font-family: var(--font-display);
font-size: clamp(26px, 4vw, 34px);
line-height: 1;
color: var(--text);
}
.db-stat__label {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-dim);
}
/* ── Sections ── */
.db-section {
margin-bottom: 28px;
}
.db-section__hd {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
padding: 0 2px;
}
.db-section__title {
font-family: var(--font-ui);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--text-dim);
}
.lb-tabs {
display: flex;
gap: 4px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px;
}
.lb-tab {
padding: 4px 14px;
background: none;
border: none;
border-radius: 4px;
font-family: var(--font-ui);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.lb-tab:hover { color: var(--text); }
.lb-tab--active {
background: var(--gold-dim);
color: var(--gold);
border: 1px solid var(--gold-mid);
}
.db-section__badge {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold);
background: var(--gold-dim);
border: 1px solid var(--gold-mid);
border-radius: 4px;
padding: 3px 9px;
}
/* ── Tier Badge ─────────────────────────────────────────────── */
.tier-badge {
display: inline-flex;
align-items: center;
font-family: var(--font-ui);
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--tier-color);
background: color-mix(in srgb, var(--tier-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--tier-color) 35%, transparent);
border-radius: 4px;
box-shadow: 0 0 10px var(--tier-glow);
white-space: nowrap;
flex-shrink: 0;
}
.tier-badge--sm {
font-size: 9px;
padding: 2px 7px;
}
/* ── Tier Progress Bar ─────────────────────────────────────── */
.tier-progress {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.tier-progress__labels {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.tier-progress__pct {
color: var(--text-muted);
font-size: 9px;
}
.tier-progress__bar {
height: 4px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.tier-progress__fill {
height: 100%;
border-radius: 2px;
transition: width 1s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── Greeting + Hero ─────────────────────────────────────────── */
.db-greeting {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}
.db-hero__namerow {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
margin-bottom: 8px;
}
.db-top-pct {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-top: 4px;
}
.db-top-pct strong {
color: var(--text);
font-weight: 700;
}
/* ── Win Streak ──────────────────────────────────────────────── */
.win-streak {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 6px auto 0;
padding: 4px 14px;
background: rgba(245, 197, 24, 0.06);
border: 1px solid rgba(245, 197, 24, 0.2);
border-radius: 99px;
animation: streakPulse 2.5s ease-in-out infinite;
}
.win-streak__fire { font-size: 14px; }
.win-streak__label {
font-family: var(--font-ui);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold);
}
@keyframes streakPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245,197,24,0); }
50% { box-shadow: 0 0 14px 2px rgba(245,197,24,0.12); }
}
/* ── ELO Hero Block ─────────────────────────────────────────── */
.db-elo-block {
margin-bottom: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px 24px;
position: relative;
overflow: hidden;
}
.db-elo-block::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(to right, transparent, var(--border-glow), transparent);
}
.db-elo-block__inner {
display: flex;
align-items: center;
gap: 28px;
}
.db-elo-main {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-shrink: 0;
gap: 2px;
}
.db-elo-val {
font-family: var(--font-display);
font-size: clamp(40px, 7vw, 58px);
line-height: 1;
}
.db-elo-label {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-dim);
margin-top: 2px;
}
.db-elo-rank {
font-family: var(--font-display);
font-size: 13px;
color: var(--text-muted);
margin-top: 4px;
}
.db-elo-rank__total {
font-size: 11px;
opacity: 0.6;
}
.db-elo-block__right {
flex: 1;
min-width: 0;
}
/* ── Two-column layout ─────────────────────────────────────── */
.db-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 28px;
}
.db-section--half {
margin-bottom: 0;
}
/* ── Match History ──────────────────────────────────────────── */
.match-history {
display: flex;
flex-direction: column;
}
.match-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
transition: background 0.15s;
}
.match-row:last-child { border-bottom: none; }
.match-row:hover { background: rgba(255,255,255,0.02); }
.match-row--win { border-left: 2px solid #22c55e; }
.match-row--loss { border-left: 2px solid #ef4444; }
.match-chip {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 13px;
flex-shrink: 0;
}
.match-chip--win { background: rgba(34,197,94,0.12); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
.match-chip--loss { background: rgba(239,68,68,0.10); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
.match-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.match-elo-after {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.02em;
}
.match-time {
font-family: var(--font-ui);
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.match-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
}
.match-elo-delta {
font-family: var(--font-display);
font-size: 15px;
}
.match-elo-delta--pos { color: #22c55e; }
.match-elo-delta--neg { color: #ef4444; }
.match-ago {
font-family: var(--font-ui);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
/* ── All-grades overview ── */
.db-grades {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.db-grade-cell {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
cursor: pointer;
transition: border-color 0.2s, transform 0.15s;
}
.db-grade-cell:hover { border-color: var(--text-muted); transform: translateY(-1px); }
.db-grade-cell--active {
border-color: var(--gold-mid);
background: var(--gold-dim);
}
.db-grade-cell__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.db-grade-cell__name {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-dim);
}
.db-grade-cell__row {
display: flex;
align-items: baseline;
gap: 6px;
}
.db-grade-cell__val {
font-family: var(--font-display);
font-size: 20px;
line-height: 1;
color: var(--text);
}
.db-grade-cell__val--elo {
color: var(--gold);
text-shadow: 0 0 10px rgba(245,197,24,0.3);
}
.db-grade-cell__unit {
font-family: var(--font-ui);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
}
/* Legacy hero — kept for non-dashboard pages if any */
.dash-hero { .dash-hero {
text-align: center; text-align: center;
padding: 48px 0 36px; padding: 48px 0 36px;
width: 100%; width: 100%;
max-width: 740px; max-width: 820px;
} }
.dash-hero__eyebrow { .dash-hero__eyebrow {
@@ -670,6 +1205,8 @@ a { color: inherit; text-decoration: none; }
gap: 16px; gap: 16px;
} }
.dash-cta__btn { .dash-cta__btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -727,6 +1264,7 @@ a { color: inherit; text-decoration: none; }
margin-bottom: 40px; margin-bottom: 40px;
} }
.stat-card { .stat-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -776,6 +1314,7 @@ a { color: inherit; text-decoration: none; }
margin-bottom: 32px; margin-bottom: 32px;
} }
.dash-section__hd { .dash-section__hd {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -984,6 +1523,38 @@ a { color: inherit; text-decoration: none; }
color: var(--text); color: var(--text);
} }
/* Ghost (empty slot) rows */
.lb-row--ghost {
pointer-events: none;
opacity: 0.28;
}
.lb-row--ghost:hover {
background: transparent;
}
.lb-avatar--ghost {
background: var(--surface-2);
border: 1px solid var(--border);
}
.lb-ghost-bar {
display: block;
height: 9px;
width: 72px;
border-radius: 5px;
background: var(--surface-2);
}
.lb-ghost-time {
font-family: var(--font-ui);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--text-muted);
flex-shrink: 0;
}
.lb-skeleton { .lb-skeleton {
padding: 12px 16px; padding: 12px 16px;
display: flex; display: flex;
@@ -1027,6 +1598,7 @@ a { color: inherit; text-decoration: none; }
color: var(--text-muted); color: var(--text-muted);
} }
/* Error banner */ /* Error banner */
.dash-error-banner { .dash-error-banner {
display: flex; display: flex;
@@ -1061,6 +1633,8 @@ a { color: inherit; text-decoration: none; }
.dash-error-banner__dismiss:hover { opacity: 1; } .dash-error-banner__dismiss:hover { opacity: 1; }
/* Loading skeleton */ /* Loading skeleton */
.skel-pill { .skel-pill {
background: var(--surface-2); background: var(--surface-2);
@@ -1290,6 +1864,86 @@ a { color: inherit; text-decoration: none; }
color: var(--text-dim); color: var(--text-dim);
} }
.settings-row__action {
padding: 6px 16px;
background: var(--gold-dim);
border: 1px solid var(--gold-mid);
border-radius: var(--radius-sm);
color: var(--gold);
font-family: var(--font-ui);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.2s, color 0.2s, transform 0.15s;
flex-shrink: 0;
}
.settings-row__action:hover {
background: var(--gold-mid);
color: #1a1a2e;
}
.settings-row__action:active {
transform: scale(0.96);
}
.settings-avatar {
position: relative;
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
border: 2px solid var(--border);
transition: border-color 0.2s, opacity 0.2s;
}
.settings-avatar:hover {
border-color: var(--gold-mid);
opacity: 0.85;
}
.settings-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.settings-avatar__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-2);
color: var(--text-muted);
}
.settings-avatar__spinner {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
border-radius: 50%;
}
.settings-avatar__spinner::after {
content: '';
position: absolute;
inset: 12px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: settings-spin 0.7s linear infinite;
}
@keyframes settings-spin {
to { transform: rotate(360deg); }
}
.settings-toggle { .settings-toggle {
position: relative; position: relative;
width: 48px; width: 48px;
@@ -1360,40 +2014,6 @@ a { color: inherit; text-decoration: none; }
color: var(--text); color: var(--text);
} }
/* ── Username nav elements ────────────────────────────────── */
.dash-nav__username {
background: none;
border: none;
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--text-dim);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: color 0.15s;
}
.dash-nav__username:hover { color: var(--text); }
.dash-nav__set-username {
background: none;
border: none;
font-family: var(--font-ui);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--gold);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
opacity: 0.75;
transition: opacity 0.15s;
}
.dash-nav__set-username:hover { opacity: 1; }
/* ── Username modal ───────────────────────────────────────── */ /* ── Username modal ───────────────────────────────────────── */
.uname-overlay { .uname-overlay {
position: fixed; position: fixed;
@@ -1514,21 +2134,47 @@ a { color: inherit; text-decoration: none; }
white-space: nowrap; white-space: nowrap;
} }
/* ── Forfeit / Quit Button ────────────────────────────────── */
.btn-forfeit {
padding: 8px 20px;
border-radius: 999px;
font-family: system-ui, sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1.5px solid #e57373;
background: #fff;
color: #c62828;
transition: background 0.15s, color 0.15s;
}
.btn-forfeit:hover {
background: #c62828;
color: #fff;
}
/* ── Responsive ───────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────── */
@media (max-width: 700px) {
.db-two-col { grid-template-columns: 1fr; }
.db-elo-block__inner { flex-direction: column; align-items: flex-start; gap: 16px; }
.db-elo-block__right { width: 100%; }
}
@media (max-width: 600px) { @media (max-width: 600px) {
.dash-nav { padding: 0 16px; } .dash-nav { padding: 0 16px; }
.dash-page { padding-left: 16px; padding-right: 16px; }
.profile-dropdown__trigger .dash-nav__name { display: none; } .profile-dropdown__trigger .dash-nav__name { display: none; }
.dash-cta__btn { padding: 16px 40px; } .db-stats { grid-template-columns: repeat(2, 1fr); }
.dash-stats { gap: 8px; } .db-stat__val { font-size: clamp(22px, 6vw, 30px); }
.stat-card { padding: 18px 10px; } .db-btn { font-size: 15px; padding: 15px 16px; }
.stat-card__value { font-size: clamp(22px, 6vw, 32px); }
.lb-row { padding: 10px 14px; gap: 10px; } .lb-row { padding: 10px 14px; gap: 10px; }
.lb-row--current { padding-left: 12px; } .lb-row--current { padding-left: 12px; }
.db-hero__namerow { flex-direction: column; gap: 8px; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.grade-badges { gap: 8px; } .grade-badges { gap: 8px; }
.grade-badge { padding: 12px 16px; } .grade-badge { padding: 12px 16px; }
.dash-grade-tab { padding: 8px 14px; font-size: 12px; } .dash-grade-tab { padding: 8px 14px; font-size: 12px; }
.dash-hero__title { font-size: clamp(44px, 14vw, 88px); } .db-hero__name { font-size: clamp(38px, 13vw, 72px); }
.db-grades { grid-template-columns: 1fr; }
} }
+389 -254
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ProfileDropdown from '../components/ProfileDropdown'; import ProfileDropdown from '../components/ProfileDropdown';
@@ -10,12 +10,29 @@ const GRADES = [
{ id: 'G9', label: 'Grade 9' }, { id: 'G9', label: 'Grade 9' },
]; ];
const LB_SLOTS = 10;
const TIERS = [
{ name: 'Bronze', min: 800, max: 899, color: '#cd7f32', glow: 'rgba(205,127,50,0.45)', next: 900 },
{ name: 'Silver', min: 900, max: 1099, color: '#b0bec5', glow: 'rgba(176,190,197,0.45)', next: 1100 },
{ name: 'Gold', min: 1100, max: 1299, color: '#f5c518', glow: 'rgba(245,197,24,0.55)', next: 1300 },
{ name: 'Platinum', min: 1300, max: 1499, color: '#90caf9', glow: 'rgba(144,202,249,0.5)', next: 1500 },
{ name: 'Diamond', min: 1500, max: Infinity, color: '#64ffda', glow: 'rgba(100,255,218,0.55)', next: null },
];
function getTier(elo) {
return TIERS.find(t => elo >= t.min && elo <= t.max) ?? TIERS[0];
}
const RANK_STYLES = [
{ color: '#f5c518', textShadow: '0 0 12px rgba(245,197,24,0.6)' },
{ color: '#c0ccda', textShadow: '0 0 10px rgba(192,204,218,0.4)' },
{ color: '#e0915a', textShadow: '0 0 10px rgba(224,145,90,0.4)' },
];
function decodeJwt(token) { function decodeJwt(token) {
try { try { return JSON.parse(atob(token.split('.')[1])); }
return JSON.parse(atob(token.split('.')[1])); catch { return null; }
} catch {
return null;
}
} }
function fmtTime(ms) { function fmtTime(ms) {
@@ -24,63 +41,134 @@ function fmtTime(ms) {
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
} }
function StatCard({ label, value, accent = false }) { function fmtRelative(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function getGreeting() {
const h = new Date().getHours();
if (h < 5) return 'Night owl mode';
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
function useCountUp(target, duration = 900, enabled = true) {
const [value, setValue] = useState(0);
const rafRef = useRef(null);
useEffect(() => {
if (!enabled || target == null || target === 0) {
setValue(target ?? 0);
return;
}
const start = Date.now();
const tick = () => {
const elapsed = Date.now() - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setValue(Math.round(eased * target));
if (progress < 1) rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [target, duration, enabled]);
return value;
}
function TierBadge({ elo, size = 'md' }) {
const tier = getTier(elo);
const isLg = size === 'lg';
return ( return (
<div className="stat-card"> <span
<span className="stat-card__label">{label}</span> className="tier-badge"
<span className={`stat-card__value${accent ? ' stat-card__value--accent' : ''}`}> style={{
{value} '--tier-color': tier.color,
</span> '--tier-glow': tier.glow,
fontSize: isLg ? 12 : 10,
padding: isLg ? '4px 12px' : '3px 9px',
}}
>
{tier.name}
</span>
);
}
function TierProgress({ elo }) {
const tier = getTier(elo);
if (!tier.next) return null;
const pct = Math.round(((elo - tier.min) / (tier.next - tier.min)) * 100);
const nextTier = TIERS.find(t => t.min === tier.next);
return (
<div className="tier-progress">
<div className="tier-progress__labels">
<span style={{ color: tier.color }}>{tier.name}</span>
<span className="tier-progress__pct">{pct}%</span>
<span style={{ color: nextTier?.color ?? tier.color }}>{nextTier?.name}</span>
</div>
<div className="tier-progress__bar">
<div
className="tier-progress__fill"
style={{ width: `${pct}%`, background: tier.color, boxShadow: `0 0 8px ${tier.glow}` }}
/>
</div>
</div> </div>
); );
} }
const RANK_STYLES = [ function LeaderboardTable({ entries, loading, highlightRank, tab }) {
{ 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) { if (loading) {
return ( return (
<div className="lb-skeleton"> <div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{[...Array(5)].map((_, i) => ( {[...Array(LB_SLOTS)].map((_, i) => (
<div key={i} className="lb-skel-row" style={{ animationDelay: `${i * 0.07}s` }} /> <div key={i} className="skel-pill" style={{ height: 44, animationDelay: `${i * 0.06}s` }} />
))} ))}
</div> </div>
); );
} }
if (!entries.length) { const padded = [
const gradeLabel = GRADES.find(g => g.id === grade)?.label ?? grade; ...entries,
return ( ...[...Array(Math.max(0, LB_SLOTS - entries.length))].map((_, i) => ({
<div className="dash-empty"> rank: entries.length + i + 1,
<span className="dash-empty__icon"></span> ghost: true,
<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 ( return (
<div className="lb-table"> <div className="lb-table">
<div className="lb-header"> <div className="lb-header">
<span className="lb-header__rank">Rank</span> <span className="lb-header__rank">#</span>
<span className="lb-header__player">Player</span> <span className="lb-header__player">Player</span>
<span className="lb-header__time">Best Time</span> <span className="lb-header__time">{tab === 'elo' ? 'ELO' : 'Best Time'}</span>
</div> </div>
{entries.map(entry => { {padded.map(entry => {
if (entry.ghost) {
return (
<div key={`g${entry.rank}`} className="lb-row lb-row--ghost">
<span className="lb-rank lb-rank--num">{entry.rank}</span>
<div className="lb-user">
<div className="lb-avatar lb-avatar--ghost" />
<span className="lb-ghost-bar" />
</div>
<span className="lb-ghost-time">{tab === 'elo' ? '—' : '—:——'}</span>
</div>
);
}
const isCurrent = highlightRank !== null && entry.rank === highlightRank; const isCurrent = highlightRank !== null && entry.rank === highlightRank;
const rankStyle = entry.rank <= 3 ? RANK_STYLES[entry.rank - 1] : null; const rankStyle = entry.rank <= 3 ? RANK_STYLES[entry.rank - 1] : null;
const playerLabel = entry.username ? `@${entry.username}` : entry.displayName; const playerLabel = entry.username ? `@${entry.username}` : entry.displayName;
const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?'; const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?';
return ( return (
<div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`}> <div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`}>
<span <span className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}>
className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`}
style={rankStyle ?? undefined}
>
{entry.rank} {entry.rank}
</span> </span>
<div className="lb-user"> <div className="lb-user">
@@ -91,7 +179,9 @@ function LeaderboardTable({ entries, loading, highlightRank, grade }) {
<span className="lb-name">{playerLabel}</span> <span className="lb-name">{playerLabel}</span>
{isCurrent && <span className="lb-you">YOU</span>} {isCurrent && <span className="lb-you">YOU</span>}
</div> </div>
<span className="lb-score lb-score--time">{fmtTime(entry.bestTime)}</span> <span className="lb-score lb-score--time">
{tab === 'elo' ? (entry.elo?.toLocaleString() ?? '—') : fmtTime(entry.bestTime)}
</span>
</div> </div>
); );
})} })}
@@ -99,63 +189,47 @@ function LeaderboardTable({ entries, loading, highlightRank, grade }) {
); );
} }
function UsernameModal({ token, current, onClose, onSave }) { function MatchHistory({ matches, loading }) {
const [value, setValue] = useState(current ?? ''); if (loading) {
const [error, setError] = useState(null); return (
const [saving, setSaving] = useState(false); <div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px' }}>
const inputRef = useRef(null); {[0,1,2].map(i => <div key={i} className="skel-pill" style={{ height: 52, animationDelay: `${i*0.07}s` }} />)}
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>
);
}
if (!matches.length) {
return (
<div className="dash-empty">
<div className="dash-empty__icon"></div>
<span className="dash-empty__text">No ranked matches yet</span>
<span className="dash-empty__sub">Queue up to start climbing</span>
</div>
);
}
return (
<div className="match-history">
{matches.map((m, i) => {
const sign = m.eloChange >= 0 ? '+' : '';
return (
<div key={m.id ?? i} className={`match-row${m.won ? ' match-row--win' : ' match-row--loss'}`}>
<div className={`match-chip${m.won ? ' match-chip--win' : ' match-chip--loss'}`}>
{m.won ? 'W' : 'L'}
</div>
<div className="match-info">
<span className="match-elo-after">{m.eloAfter.toLocaleString()} ELO</span>
{m.timeSpentMs != null && (
<span className="match-time">{fmtTime(m.timeSpentMs)}</span>
)}
</div>
<div className="match-right">
<span className={`match-elo-delta${m.eloChange >= 0 ? ' match-elo-delta--pos' : ' match-elo-delta--neg'}`}>
{sign}{m.eloChange}
</span>
<span className="match-ago">{fmtRelative(m.playedAt)}</span>
</div>
</div>
);
})}
</div> </div>
); );
} }
@@ -166,25 +240,17 @@ function LoadingSkeleton() {
<div className="arena-bg"><div className="arena-grain" /></div> <div className="arena-bg"><div className="arena-grain" /></div>
<nav className="dash-nav"> <nav className="dash-nav">
<div className="skel-pill" style={{ width: 140, height: 22 }} /> <div className="skel-pill" style={{ width: 140, height: 22 }} />
<div className="skel-pill" style={{ width: 180, height: 36, borderRadius: 999 }} /> <div className="skel-pill" style={{ width: 40, height: 40, borderRadius: '50%' }} />
</nav> </nav>
<main className="page dash-page"> <main className="page dash-page">
<section className="dash-hero"> <div className="db-wrap">
<div className="skel-pill" style={{ width: 96, height: 12, marginBottom: 16 }} /> <div className="skel-pill" style={{ width: 280, height: 72, marginBottom: 24, alignSelf: 'center' }} />
<div className="skel-pill" style={{ width: 300, height: 64, marginBottom: 32 }} /> <div className="skel-pill" style={{ width: 240, height: 40, borderRadius: 999, marginBottom: 40, alignSelf: 'center' }} />
<div style={{ display: 'flex', gap: 8 }}> <div className="skel-pill" style={{ height: 60, marginBottom: 28 }} />
{[0, 1, 2].map(i => ( <div className="db-stats">
<div key={i} className="skel-pill" style={{ width: 96, height: 38, animationDelay: `${i * 0.08}s` }} /> {[0,1,2,3].map(i => <div key={i} className="skel-pill" style={{ height: 88, animationDelay: `${i*0.07}s` }} />)}
))}
</div> </div>
</section> <div className="skel-pill" style={{ height: 480 }} />
<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> </div>
</main> </main>
</> </>
@@ -197,20 +263,21 @@ export default function Dashboard() {
const [jwtPayload, setJwtPayload] = useState(null); const [jwtPayload, setJwtPayload] = useState(null);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [username, setUsername] = useState(null); const [username, setUsername] = useState(null);
const [usernameModal, setUsernameModal] = useState(false);
const [stats, setStats] = useState([]); const [stats, setStats] = useState([]);
const [leaderboard, setLeaderboard] = useState([]); const [leaderboard, setLeaderboard] = useState([]);
const [matches, setMatches] = useState([]);
const [selectedGrade, setSelectedGrade] = useState('G3'); const [selectedGrade, setSelectedGrade] = useState('G3');
const [lbTab, setLbTab] = useState('elo');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [lbLoading, setLbLoading] = useState(false); const [lbLoading, setLbLoading] = useState(false);
const [matchLoading, setMatchLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const profileElo = stats.find(e => e.grade === selectedGrade)?.elo ?? null; const [statsReady, setStatsReady] = useState(false);
// ── Auth init ──
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token'); const urlToken = params.get('token');
let tok = urlToken; let tok = urlToken;
if (urlToken) { if (urlToken) {
sessionStorage.setItem('ranked_token', urlToken); sessionStorage.setItem('ranked_token', urlToken);
history.replaceState(null, '', window.location.pathname); history.replaceState(null, '', window.location.pathname);
@@ -222,183 +289,251 @@ export default function Dashboard() {
setJwtPayload(decodeJwt(tok)); setJwtPayload(decodeJwt(tok));
}, [navigate]); }, [navigate]);
// ── Fetch user + stats ──
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
const headers = { Authorization: `Bearer ${token}` }; const headers = { Authorization: `Bearer ${token}` };
Promise.all([ Promise.all([fetch(`${API}/auth/me`, { headers }), fetch(`${API}/me/stats`, { headers })])
fetch(`${API}/auth/me`, { headers }),
fetch(`${API}/me/stats`, { headers }),
])
.then(async ([meRes, statsRes]) => { .then(async ([meRes, statsRes]) => {
if (meRes.status === 401) { if (meRes.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return; }
sessionStorage.removeItem('ranked_token');
setLoading(false);
navigate('/');
return;
}
const [meData, statsData] = await Promise.all([meRes.json(), statsRes.json()]); const [meData, statsData] = await Promise.all([meRes.json(), statsRes.json()]);
setUser(meData); setUser(meData);
setUsername(meData.username ?? null); setUsername(meData.username ?? null);
setStats(statsData.entries ?? []); setStats(statsData.entries ?? []);
setLoading(false); setLoading(false);
requestAnimationFrame(() => setStatsReady(true));
}) })
.catch(() => { .catch(() => { setError('Could not reach the server.'); setLoading(false); });
setError('Could not reach the server. Check your connection and refresh.');
setLoading(false);
});
}, [token, navigate]); }, [token, navigate]);
// ── Fetch leaderboard ──
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
setLbLoading(true); setLbLoading(true);
fetch(`${API}/leaderboard?grade=${selectedGrade}`) fetch(`${API}/leaderboard?grade=${selectedGrade}&type=${lbTab}`)
.then(res => res.json()) .then(r => r.json())
.then(data => setLeaderboard(Array.isArray(data) ? data : [])) .then(d => setLeaderboard(Array.isArray(d) ? d : []))
.catch(() => setLeaderboard([])) .catch(() => setLeaderboard([]))
.finally(() => setLbLoading(false)); .finally(() => setLbLoading(false));
}, [token, selectedGrade, lbTab]);
useEffect(() => {
if (!token) return;
setMatchLoading(true);
fetch(`${API}/me/recent-matches?grade=${selectedGrade}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.json())
.then(d => setMatches(Array.isArray(d) ? d : []))
.catch(() => setMatches([]))
.finally(() => setMatchLoading(false));
}, [token, selectedGrade]); }, [token, selectedGrade]);
const signOut = () => { const signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); };
sessionStorage.removeItem('ranked_token');
navigate('/');
};
const currentEntry = stats.find(e => e.grade === selectedGrade); const currentEntry = stats.find(e => e.grade === selectedGrade);
const highlightRank = currentEntry?.timeRank ?? null; const highlightRank = lbTab === 'elo' ? (currentEntry?.eloRank ?? null) : (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() ?? '?';
const elo = currentEntry?.elo ?? 800;
const tier = getTier(elo);
const winStreak = currentEntry?.winStreak ?? 0;
const eloRank = currentEntry?.eloRank ?? null;
const totalPlayers = currentEntry?.totalPlayers ?? 0;
const gamesPlayed = currentEntry?.gamesPlayed ?? 0;
const displayName = user?.displayName ?? jwtPayload?.name ?? 'Player'; const topPct = eloRank && totalPlayers > 0
const firstName = displayName.split(' ')[0]; ? Math.max(1, Math.round((eloRank / totalPlayers) * 100))
const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null; : null;
const initial = displayName[0]?.toUpperCase() ?? '?';
const animatedElo = useCountUp(elo, 1000, statsReady);
const animatedGames = useCountUp(gamesPlayed, 800, statsReady);
if (loading) return <LoadingSkeleton />; if (loading) return <LoadingSkeleton />;
const heroLabel = username ?? firstName;
const casualLabel = gamesPlayed === 0 ? 'Play First Game' : 'Casual';
return ( return (
<> <>
<div className="arena-bg"><div className="arena-grain" /></div> <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"> <nav className="dash-nav">
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}> <span className="dash-nav__logo">RANKED<span>EQAO</span></span>
<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"> <div className="dash-nav__right">
{username <ProfileDropdown displayName={displayName} avatarUrl={avatarUrl} initial={initial} onSignOut={signOut} />
? <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> </div>
</nav> </nav>
<main className="page dash-page"> <main className="page dash-page">
<div className="db-wrap">
{/* ── Hero ── */} {error && (
<section className="dash-hero"> <div className="dash-error-banner" style={{ maxWidth: '100%', marginBottom: 24 }}>
<h1 className="dash-hero__title">{username ?? firstName}</h1> <span>{error}</span>
<div className="dash-grade-tabs"> <button className="dash-error-banner__dismiss" onClick={() => setError(null)}></button>
{GRADES.map(g => ( </div>
<button )}
key={g.id}
className={`dash-grade-tab${selectedGrade === g.id ? ' dash-grade-tab--active' : ''}`} {/* ── Hero ── */}
onClick={() => setSelectedGrade(g.id)} <section className="db-hero">
> <p className="db-greeting">{getGreeting()},</p>
{g.label} <div className="db-hero__namerow">
</button> <h1 className="db-hero__name">{heroLabel}</h1>
))} <TierBadge elo={elo} size="lg" />
</div>
{winStreak >= 2 && (
<div className="win-streak">
<span className="win-streak__fire">🔥</span>
<span className="win-streak__label">{winStreak} win streak</span>
</div>
)}
{topPct && (
<p className="db-top-pct">
Top <strong>{topPct}%</strong> in Grade {selectedGrade.replace('G', '')}
</p>
)}
<div className="dash-grade-tabs" style={{ marginTop: 20 }}>
{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="db-cta">
<button className="db-btn db-btn--gold" onClick={() => navigate('/game', { state: { grade: selectedGrade } })}>
{casualLabel}
</button>
<button className="db-btn db-btn--blue" onClick={() => navigate('/queue', { state: { grade: selectedGrade } })}>
Queue Ranked
</button>
</div> </div>
</section>
{/* ── CTA Buttons ── */} {/* ── ELO + Tier progress ── */}
<div className="dash-cta"> <div className="db-elo-block">
<button className="dash-cta__btn" onClick={() => navigate('/game', { state: { grade: selectedGrade } })}> <div className="db-elo-block__inner">
<span className="dash-cta__label">Start Game</span> <div className="db-elo-main">
</button> <span className="db-elo-val" style={{ color: tier.color, textShadow: `0 0 24px ${tier.glow}` }}>
<button className="dash-cta__btn dash-cta__btn--ranked" onClick={() => navigate('/queue', { state: { grade: selectedGrade } })}> {animatedElo.toLocaleString()}
<span className="dash-cta__label">Queue Ranked</span> </span>
</button> <span className="db-elo-label">ELO</span>
</div> {eloRank && (
<span className="db-elo-rank">
{/* ── Error banner ── */} #{eloRank}
{error && ( {totalPlayers > 0 && <span className="db-elo-rank__total"> / {totalPlayers}</span>}
<div className="dash-error-banner"> </span>
<span>{error}</span> )}
<button className="dash-error-banner__dismiss" onClick={() => setError(null)}></button> </div>
</div> <div className="db-elo-block__right">
)} <TierProgress elo={elo} />
</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>
</div> </div>
</section>
{/* ── Stats strip ── */}
<div className="db-stats">
{[
{ label: 'Best Time', value: fmtTime(currentEntry?.bestTime), accent: true },
{ label: 'Games', value: animatedGames },
{ label: 'Correct', value: currentEntry?.totalCorrect ?? 0 },
{ label: 'Time Rank', value: currentEntry?.timeRank != null ? `#${currentEntry.timeRank}` : '—' },
].map(s => (
<div key={s.label} className="db-stat">
<span className="db-stat__val" style={s.accent ? { color: 'var(--gold)', textShadow: '0 0 18px rgba(245,197,24,0.35)' } : {}}>
{s.value}
</span>
<span className="db-stat__label">{s.label}</span>
</div>
))}
</div>
{/* ── Recent Matches + Leaderboard side by side ── */}
<div className="db-two-col">
{/* Recent Matches */}
<section className="db-section db-section--half">
<div className="db-section__hd">
<span className="db-section__title">Recent Matches</span>
{winStreak >= 2 && (
<span className="db-section__badge" style={{ background: 'rgba(245,197,24,0.08)', borderColor: 'rgba(245,197,24,0.3)', color: 'var(--gold)' }}>
🔥 {winStreak} streak
</span>
)}
</div>
<div className="dash-card">
<MatchHistory matches={matches} loading={matchLoading} />
</div>
</section>
{/* Leaderboard */}
<section className="db-section db-section--half">
<div className="db-section__hd">
<span className="db-section__title">Leaderboard</span>
<div className="lb-tabs">
<button className={`lb-tab${lbTab === 'elo' ? ' lb-tab--active' : ''}`} onClick={() => setLbTab('elo')}>ELO</button>
<button className={`lb-tab${lbTab === 'time' ? ' lb-tab--active' : ''}`} onClick={() => setLbTab('time')}>Time</button>
</div>
</div>
<div className="dash-card">
<LeaderboardTable entries={leaderboard} loading={lbLoading} highlightRank={highlightRank} tab={lbTab} />
</div>
</section>
</div>
{/* ── All-grades overview ── */}
<section className="db-section">
<div className="db-section__hd">
<span className="db-section__title">All Grades</span>
</div>
<div className="db-grades">
{GRADES.map(g => {
const e = stats.find(s => s.grade === g.id);
const t = getTier(e?.elo ?? 800);
return (
<div
key={g.id}
className={`db-grade-cell${selectedGrade === g.id ? ' db-grade-cell--active' : ''}`}
onClick={() => setSelectedGrade(g.id)}
>
<div className="db-grade-cell__top">
<span className="db-grade-cell__name">{g.label}</span>
<span className="tier-badge tier-badge--sm" style={{ '--tier-color': t.color, '--tier-glow': t.glow }}>
{t.name}
</span>
</div>
<div className="db-grade-cell__row">
<span className="db-grade-cell__val db-grade-cell__val--elo" style={{ color: t.color, textShadow: `0 0 10px ${t.glow}` }}>
{e?.elo?.toLocaleString() ?? '800'}
</span>
<span className="db-grade-cell__unit">ELO</span>
</div>
<div className="db-grade-cell__row">
<span className="db-grade-cell__val">{fmtTime(e?.bestTime)}</span>
<span className="db-grade-cell__unit">best</span>
</div>
<div className="db-grade-cell__row">
<span className="db-grade-cell__val">{e?.gamesPlayed ?? 0}</span>
<span className="db-grade-cell__unit">games</span>
</div>
</div>
);
})}
</div>
</section>
</div>
</main> </main>
</> </>
); );
+54 -37
View File
@@ -63,6 +63,12 @@ const NAV_BTN = {
border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8', border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8',
}; };
const QUIT_BTN = {
padding: '14px 28px', borderRadius: 999, fontFamily: 'system-ui,sans-serif',
fontSize: 14, fontWeight: 600, cursor: 'pointer',
border: '1.5px solid #e57373', background: '#fff', color: '#c62828',
};
const WRAP = { display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' }; const WRAP = { display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' };
// ── Screens ──────────────────────────────────────────────────── // ── Screens ────────────────────────────────────────────────────
@@ -89,7 +95,7 @@ function StageIntro({ stage, onNext }) {
); );
} }
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump }) { function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, onQuit }) {
useEffect(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
const i = +e.key - 1; const i = +e.key - 1;
@@ -147,15 +153,18 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
</div> </div>
</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 }}> <div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'space-between', gap: 14 }}>
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}> Back</button>} <button style={QUIT_BTN} onClick={onQuit}>Quit</button>
{qIdx === STAGE_SIZE - 1 ? ( <div style={{ display: 'flex', gap: 14 }}>
<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}> {qIdx > 0 && <button style={NAV_BTN} onClick={onBack}> Back</button>}
Submit {qIdx === STAGE_SIZE - 1 ? (
</button> <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 style={NAV_BTN} onClick={onNext}>Next </button> </button>
)} ) : (
<button style={NAV_BTN} onClick={onNext}>Next </button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -256,22 +265,7 @@ function StageResult({ stage, wrongCount, questions, answers: initialAnswers, on
); );
} }
function CompleteScreen({ duration, grade, onDashboard }) { function CompleteScreen({ duration, isNewBest, 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 ( return (
<div style={WRAP}> <div style={WRAP}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
@@ -281,9 +275,9 @@ function CompleteScreen({ duration, grade, onDashboard }) {
<div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}> <div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}>
{fmtDuration(duration)} {fmtDuration(duration)}
</div> </div>
{pbState?.updated && ( {isNewBest && (
<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' }}> <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', boxShadow: '0 0 12px rgba(245,197,24,0.45)' }}>
New Personal Best! 🏆 New Personal Best!
</div> </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' }}> <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' }}>
@@ -307,10 +301,11 @@ export default function Game() {
const [qIdx, setQIdx] = useState(0); const [qIdx, setQIdx] = useState(0);
const [answers, setAnswers] = useState({}); const [answers, setAnswers] = useState({});
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [wrongCount, setWrongCount] = useState(0); const [wrongCount, setWrongCount] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [fetchError, setFetchError] = useState(false); const [fetchError, setFetchError] = useState(false);
const [isNewBest, setIsNewBest] = useState(false);
const timerStartRef = useRef(null); const timerStartRef = useRef(null);
const intervalRef = useRef(null); const intervalRef = useRef(null);
@@ -418,13 +413,35 @@ export default function Game() {
setPhase('question'); setPhase('question');
} }
function handleResultNext() { function handleQuit() {
if (!window.confirm('Are you sure you want to quit? Your progress will be lost.')) return;
stopTimer();
navigate('/dashboard');
}
async function handleResultNext() {
if (stage === 1) { if (stage === 1) {
s1CorrectRef.current = STAGE_SIZE - wrongCount; s1CorrectRef.current = STAGE_SIZE - wrongCount;
loadStage(2); loadStage(2);
} else { } else {
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount)); endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
setDuration(stopTimer()); const token = sessionStorage.getItem('ranked_token');
const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0;
stopTimer();
let isNewBest = false;
try {
const res = await fetch(`${API}/me/best-time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ grade, timeMs }),
});
const data = await res.json();
isNewBest = data.updated === true;
} catch (e) {
console.error('Failed to record best time:', e);
}
setDuration(timeMs);
setIsNewBest(isNewBest);
setPhase('complete'); setPhase('complete');
} }
} }
@@ -441,9 +458,9 @@ export default function Game() {
</div> </div>
); );
if (phase === 'intro') return <StageIntro stage={stage} onNext={() => { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />; 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 === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} onQuit={handleQuit} />;
if (phase === 'result') return <StageResult stage={stage} wrongCount={wrongCount} questions={questions} answers={answers} onRetry={handleRetry} onNext={handleResultNext} />; 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')} />; if (phase === 'complete') return <CompleteScreen duration={duration} isNewBest={isNewBest} onDashboard={() => navigate('/dashboard')} />;
return null; return null;
} }
+50 -383
View File
@@ -2,31 +2,14 @@ import { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router-dom'; import { useNavigate, useLocation, Navigate } from 'react-router-dom';
const VALID_GRADES = new Set(['G3', 'G6', 'G9']); const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
const STAGE_SIZE = 11;
const API = import.meta.env.VITE_API_URL; 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 }) { function authHeaders() {
const pct = (progress / total) * 100; const token = sessionStorage.getItem('ranked_token');
return ( return token ? { Authorization: `Bearer ${token}` } : {};
<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 }) { function FindingScreen({ elapsed, onBack }) {
const [dots, setDots] = useState(''); const [dots, setDots] = useState('');
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []); useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
const minutes = Math.floor(elapsed / 60); const minutes = Math.floor(elapsed / 60);
@@ -40,165 +23,11 @@ function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) {
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')} Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
</div> </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> <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> <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div> </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() { export default function Queue() {
const navigate = useNavigate(); const navigate = useNavigate();
const { state } = useLocation(); const { state } = useLocation();
@@ -206,73 +35,19 @@ export default function Queue() {
const [myName, setMyName] = useState(''); const [myName, setMyName] = useState('');
const [myAvatar, setMyAvatar] = useState(null); 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 [queueElapsed, setQueueElapsed] = useState(0);
const [showForceMatch, setShowForceMatch] = useState(false);
const gameIdRef = useRef(null);
const pollRef = useRef(null); const pollRef = useRef(null);
const completedRef = useRef(false);
const submittedRef = useRef(false);
const joinedRef = useRef(false); const joinedRef = useRef(false);
const completedRef = useRef(false);
const leaveTimerRef = useRef(null); const leaveTimerRef = useRef(null);
const leavingRef = useRef(false);
// Elapsed timer while finding // Elapsed timer
useEffect(() => { useEffect(() => {
if (phase !== 'finding') return;
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000); const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
return () => clearInterval(id); 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 // Fetch own user info
useEffect(() => { useEffect(() => {
@@ -288,43 +63,43 @@ export default function Queue() {
// Join queue on mount // Join queue on mount
useEffect(() => { useEffect(() => {
// Cancel any pending leave from a previous unmount (Strict Mode guard) // Cancel any pending leave from a previous mount (handles StrictMode double-invoke)
if (leaveTimerRef.current) { if (leaveTimerRef.current) {
clearTimeout(leaveTimerRef.current); clearTimeout(leaveTimerRef.current);
leaveTimerRef.current = null; leaveTimerRef.current = null;
} }
// Reset leaving flag so callbacks work on this mount
leavingRef.current = false;
joinedRef.current = false;
completedRef.current = false;
let cancelled = false;
console.log('[queue] joining with grade', grade);
fetch(`${API}/queue/join`, { fetch(`${API}/queue/join`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() }, headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ grade }), body: JSON.stringify({ grade }),
}) })
.then(r => { .then(r => {
if (r.status === 401) { console.log('[queue] 401 on join'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json(); return r.json();
}) })
.then(data => { .then(data => {
console.log('[queue] join response:', JSON.stringify(data)); if (!data || leavingRef.current) return;
if (!data || cancelled) return;
joinedRef.current = true; joinedRef.current = true;
if (data.status === 'waiting') { if (data.status === 'waiting') {
pollQueue(); pollQueue();
} else if (data.status === 'matched') { } else if (data.status === 'matched') {
fetchMatchData(data.gameId); fetchMatchData();
} }
}) })
.catch(e => console.log('[queue] join error:', e)); .catch(() => {});
return () => { return () => {
cancelled = true; leavingRef.current = true;
clearInterval(pollRef.current); clearInterval(pollRef.current);
if (joinedRef.current && !gameIdRef.current && !completedRef.current) { if (joinedRef.current && !completedRef.current) {
leaveTimerRef.current = setTimeout(() => { leaveTimerRef.current = setTimeout(() => {
fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {}); fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {});
}, 300); }, 0);
} }
}; };
}, [grade, navigate]); }, [grade, navigate]);
@@ -333,21 +108,15 @@ export default function Queue() {
pollRef.current = setInterval(() => { pollRef.current = setInterval(() => {
fetch(`${API}/queue/status`, { headers: authHeaders() }) fetch(`${API}/queue/status`, { headers: authHeaders() })
.then(r => { .then(r => {
if (r.status === 401) { console.log('[queue] 401 on poll'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json(); return r.json();
}) })
.then(data => { .then(data => {
if (!data) return; if (!data || leavingRef.current) return;
if (data.status === 'matched') { if (data.status === 'matched') {
console.log('[queue] poll found match!');
clearInterval(pollRef.current); clearInterval(pollRef.current);
gameIdRef.current = data.gameId; navigateToGame(data);
setQuestions(data.questions);
setOpponentName(data.opponent.username ?? data.opponent.displayName);
setOpponentAvatar(data.opponent.avatarUrl);
setPhase('intro');
} else if (data.status === 'not_in_queue') { } else if (data.status === 'not_in_queue') {
console.log('[queue] poll got not_in_queue, rejoining');
clearInterval(pollRef.current); clearInterval(pollRef.current);
fetch(`${API}/queue/join`, { fetch(`${API}/queue/join`, {
method: 'POST', method: 'POST',
@@ -355,151 +124,49 @@ export default function Queue() {
body: JSON.stringify({ grade }), body: JSON.stringify({ grade }),
}) })
.then(r => { .then(r => {
if (r.status === 401) { console.log('[queue] 401 on rejoin'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json(); return r.json();
}) })
.then(d => { .then(d => {
console.log('[queue] rejoin response:', JSON.stringify(d)); if (!d || leavingRef.current) return;
if (!d) return;
if (d.status === 'waiting') pollQueue(); if (d.status === 'waiting') pollQueue();
else if (d.status === 'matched') fetchMatchData(d.gameId); else if (d.status === 'matched') navigateToGame(d);
}) })
.catch(() => {}); .catch(() => {});
} }
}) })
.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); }, 1000);
} }
function handleNext() { function fetchMatchData() {
const newAnswers = { ...answers, [qIdx]: selected }; fetch(`${API}/queue/status`, { headers: authHeaders() })
setAnswers(newAnswers); .then(r => r.json())
.then(data => {
const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; if (!leavingRef.current && data.status === 'matched') {
const elapsed = Date.now() - startTime; navigateToGame(data);
}
if (qIdx < STAGE_SIZE - 1) { })
submitProgress(qIdx + 1, correctCount, false); .catch(() => {});
const next = qIdx + 1;
setQIdx(next);
setSelected(newAnswers[next] ?? null);
} else {
submittedRef.current = true;
submitProgress(STAGE_SIZE, correctCount, true, elapsed);
setPhase('waiting');
}
} }
function handleBack() { function navigateToGame(data) {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; clearInterval(pollRef.current);
setAnswers(newAnswers); completedRef.current = true;
const prev = qIdx - 1; navigate('/ranked-game', {
setQIdx(prev); state: {
setSelected(newAnswers[prev] ?? null); gameId: data.gameId,
} questions: data.questions,
opponentName: data.opponent.username ?? data.opponent.displayName,
function handleJump(targetIdx) { opponentAvatar: data.opponent.avatarUrl,
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; myName,
setAnswers(newAnswers); myAvatar,
setQIdx(targetIdx); grade,
setSelected(newAnswers[targetIdx] ?? null); },
});
} }
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />; if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
if (phase === 'finding') return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} />;
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;
} }
+381
View File
@@ -0,0 +1,381 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
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',
};
const FORFEIT_BTN = {
padding: '14px 28px', borderRadius: 999, fontFamily: 'system-ui,sans-serif',
fontSize: 14, fontWeight: 600, cursor: 'pointer',
border: '1.5px solid #e57373', background: '#fff', color: '#c62828',
};
function authHeaders() {
const token = sessionStorage.getItem('ranked_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
function OpponentProgressStrip({ progress, total, name, dced, disconnected }) {
const items = [];
for (let qi = 0; qi < total; qi++) {
const filled = qi < progress;
items.push(
<div key={`b-${qi}`} style={{ width: 30, height: 30, borderRadius: '50%', border: `2px solid ${filled ? '#e67e22' : '#ccc'}`, background: filled ? '#e67e22' : '#fff', color: filled ? '#fff' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s' }}>{qi + 1}</div>
);
if (qi < total - 1) {
items.push(
<div key={`l-${qi}`} style={{ flex: 1, height: 2, minWidth: 2, background: filled ? '#e67e22' : '#ddd' }} />
);
}
}
return (
<div style={{ marginTop: 8, padding: '10px 0', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: (dced || disconnected) ? '#c62828' : '#e67e22', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif' }}>{name}</span>
{dced && <span style={{ fontSize: 10, fontWeight: 700, color: '#fff', background: '#c62828', borderRadius: 4, padding: '1px 5px', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.04em' }}>DC'd</span>}
{!dced && disconnected && <span style={{ fontSize: 10, fontWeight: 700, color: '#fff', background: '#e57373', borderRadius: 4, padding: '1px 5px', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.04em' }}>disconnected?</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%', opacity: (dced || disconnected) ? 0.5 : 1 }}>
{items}
</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 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 QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName, opponentDced, opponentConnected, onForfeit }) {
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>
<OpponentProgressStrip progress={opponentProgress} total={STAGE_SIZE} name={opponentName} dced={opponentDced} disconnected={!opponentConnected && !opponentDced} />
</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: 'space-between', gap: 14 }}>
<button style={FORFEIT_BTN} onClick={onForfeit}>Forfeit</button>
<div style={{ display: 'flex', 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>
</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, opponentDced, opponentConnected, onForfeit }) {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
{opponentDced
? <div style={{ fontSize: 48 }}>🔌</div>
: <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: opponentDced ? '#c62828' : '#333', margin: 0 }}>
{opponentDced ? `${opponentName} disconnected!` : `Waiting for ${opponentName}...`}
</h2>
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>
{opponentDced ? 'Awarding victory...' : 'You finished! Now waiting for your opponent to complete.'}
</p>
<div style={{ width: '100%', maxWidth: 480, marginTop: 8 }}>
<OpponentProgressStrip progress={opponentProgress} total={STAGE_SIZE} name={opponentName} dced={opponentDced} disconnected={!opponentConnected && !opponentDced} />
</div>
<button onClick={onForfeit} style={{ marginTop: 16, ...FORFEIT_BTN }}>Forfeit & Leave</button>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
function CompleteScreen({ won, opponentName, duration, eloChange, opponentDced, youDced, onDashboard }) {
const subtitle = opponentDced && won
? `${opponentName} disconnected. Victory by default.`
: youDced && !won
? `You disconnected. ${opponentName} wins.`
: won
? `You beat ${opponentName}! Well played.`
: `${opponentName} beat you. Better luck next time.`;
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 ? (opponentDced ? '🔌' : '🏆') : '💀'}</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 }}>{subtitle}</p>
{duration > 0 && (
<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>
);
}
export default function RankedGame() {
const navigate = useNavigate();
const { state } = useLocation();
if (!state || !state.gameId || !state.questions) {
return <Navigate to="/dashboard" replace />;
}
const { gameId, questions: initialQuestions, opponentName: initOppName, opponentAvatar: initOppAvatar, myName: initMyName, myAvatar: initMyAvatar, grade } = state;
const [phase, setPhase] = useState('intro');
const [questions] = useState(initialQuestions);
const [qIdx, setQIdx] = useState(0);
const [answers, setAnswers] = useState({});
const [selected, setSelected] = useState(null);
const [opponentProgress, setOpponentProgress] = useState(0);
const [opponentName] = useState(initOppName);
const [opponentAvatar] = useState(initOppAvatar);
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 [opponentDced, setOpponentDced] = useState(false);
const [youDced, setYouDced] = useState(false);
const [opponentConnected, setOpponentConnected] = useState(true);
const pollRef = useRef(null);
const completedRef = useRef(false);
const submittedRef = useRef(false);
useEffect(() => {
return () => clearInterval(pollRef.current);
}, []);
function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) {
fetch(`${API}/game/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId, currentQuestion, correctAnswers, finished, timeSpentMs }),
}).catch(() => {});
}
function pollGameStatus() {
pollRef.current = setInterval(() => {
if (completedRef.current) { clearInterval(pollRef.current); return; }
fetch(`${API}/game/status/${gameId}`, { headers: authHeaders() })
.then(r => r.json())
.then(data => {
if (data.opponentDced) setOpponentDced(true);
if (data.youDced) setYouDced(true);
if (data.opponentConnected === false) setOpponentConnected(false);
else if (data.opponentConnected === true && !data.opponentDced) setOpponentConnected(true);
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;
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);
}
async function handleForfeit() {
if (!window.confirm('Are you sure you want to forfeit? This counts as a loss.')) return;
clearInterval(pollRef.current);
completedRef.current = true;
try {
await fetch(`${API}/game/forfeit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId }),
});
} catch {}
navigate('/dashboard');
}
if (phase === 'intro')
return <StageIntro grade={grade} myName={initMyName} myAvatar={initMyAvatar} 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}
opponentDced={opponentDced} opponentConnected={opponentConnected}
onForfeit={handleForfeit}
/>
{showDefeatOverlay && (
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
)}
</>
);
if (phase === 'waiting')
return (
<>
<WaitingScreen opponentName={opponentName} opponentProgress={opponentProgress} opponentDced={opponentDced} opponentConnected={opponentConnected} onForfeit={handleForfeit} />
{showDefeatOverlay && (
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
)}
</>
);
if (phase === 'complete')
return (
<CompleteScreen
won={playerWon} opponentName={opponentName} duration={duration}
eloChange={eloChange} opponentDced={opponentDced} youDced={youDced} onDashboard={() => navigate('/dashboard')}
/>
);
return null;
}
+90
View File
@@ -1,14 +1,72 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import UsernameModal from '../components/UsernameModal';
const API = import.meta.env.VITE_API_URL;
export default function Settings() { export default function Settings() {
const navigate = useNavigate(); const navigate = useNavigate();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const [token, setToken] = useState(null);
const [username, setUsername] = useState(null);
const [avatarUrl, setAvatarUrl] = useState(null);
const [usernameModal, setUsernameModal] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(null);
useEffect(() => {
const tok = sessionStorage.getItem('ranked_token');
if (!tok) { navigate('/'); return; }
setToken(tok);
}, [navigate]);
useEffect(() => {
if (!token) return;
fetch(`${API}/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => { if (res.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } return res.json(); })
.then(data => { if (data) { setUsername(data.username ?? null); setAvatarUrl(data.avatarUrl ?? null); } })
.catch(() => {});
}, [token, navigate]);
async function handleFileSelect(e) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) { alert('Image too large. Max 2MB.'); return; }
setUploading(true);
const reader = new FileReader();
reader.onload = async (event) => {
try {
const res = await fetch(`${API}/me/avatar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ image: event.target.result }),
});
const data = await res.json();
if (res.ok) setAvatarUrl(data.avatarUrl);
else alert(data.error || 'Upload failed');
} catch {
alert('Could not upload. Check your connection.');
} finally {
setUploading(false);
}
};
reader.readAsDataURL(file);
}
return ( return (
<> <>
<div className="arena-bg"><div className="arena-grain" /></div> <div className="arena-bg"><div className="arena-grain" /></div>
{usernameModal && (
<UsernameModal
token={token}
current={username}
onClose={() => setUsernameModal(false)}
onSave={(u) => setUsername(u)}
/>
)}
<nav className="dash-nav"> <nav className="dash-nav">
<span className="dash-nav__logo">RANKED<span>EQAO</span></span> <span className="dash-nav__logo">RANKED<span>EQAO</span></span>
<button className="settings-back" onClick={() => navigate('/dashboard')}>Back</button> <button className="settings-back" onClick={() => navigate('/dashboard')}>Back</button>
@@ -19,6 +77,38 @@ export default function Settings() {
<h1 className="settings-title">Settings</h1> <h1 className="settings-title">Settings</h1>
<div className="settings-card"> <div className="settings-card">
<div className="settings-row">
<div className="settings-row__info">
<span className="settings-row__label">Profile Picture</span>
<span className="settings-row__desc">
{avatarUrl ? 'Click to change' : 'Upload a photo'}
</span>
</div>
<div className="settings-avatar" onClick={() => fileInputRef.current?.click()}>
{avatarUrl ? (
<img className="settings-avatar__img" src={avatarUrl} alt="Avatar" referrerPolicy="no-referrer" />
) : (
<div className="settings-avatar__placeholder">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</div>
)}
{uploading && <div className="settings-avatar__spinner" />}
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileSelect} style={{ display: 'none' }} />
</div>
</div>
<div className="settings-row">
<div className="settings-row__info">
<span className="settings-row__label">Username</span>
<span className="settings-row__desc">
{username ? `@${username}` : 'Not set \u2014 choose a unique name'}
</span>
</div>
<button className="settings-row__action" onClick={() => setUsernameModal(true)}>
{username ? 'Change' : 'Set'}
</button>
</div>
<div className="settings-row"> <div className="settings-row">
<div className="settings-row__info"> <div className="settings-row__info">
<span className="settings-row__label">Theme</span> <span className="settings-row__label">Theme</span>
+2 -5
View File
@@ -1,11 +1,8 @@
Prisma DB: npx prisma studio Prisma DB: npx prisma studio
Run: cd frontend && npm run dev
Express server: node src/index.js Express server: node src/index.js
Run: cd frontend && npm run dev
KILL 3000: kill $(lsof -ti:3000) KILL 3000: kill $(lsof -ti:3000)
push: git -c http.proxy=socks5://127.0.0.1:9149 push origin main 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 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
+1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js",
"studio": "prisma studio",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"repository": { "repository": {
@@ -0,0 +1,21 @@
/*
Warnings:
- Made the column `username` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- Backfill: assign a unique username derived from email or googleId for rows with NULL username
UPDATE "User"
SET "username" = lower(regexp_replace(split_part("email", '@', 1), '[^a-zA-Z0-9_]', '', 'g'))
WHERE "username" IS NULL;
-- Ensure uniqueness by appending a suffix for any collisions
UPDATE "User" u
SET "username" = u."username" || '_' || substr(u."id", 1, 6)
WHERE (
SELECT COUNT(*) FROM "User" u2
WHERE u2."username" = u."username" AND u2."id" != u."id"
) > 0;
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "username" SET NOT NULL;
+16 -2
View File
@@ -36,13 +36,14 @@ model User {
googleId String @unique googleId String @unique
email String @unique email String @unique
displayName String displayName String
username String? @unique username String @unique
avatarUrl String? avatarUrl String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions GameSession[] sessions GameSession[]
leaderboardEntries LeaderboardEntry[] leaderboardEntries LeaderboardEntry[]
matchResults MatchResult[]
} }
model Question { model Question {
@@ -103,3 +104,16 @@ model LeaderboardEntry {
@@unique([userId, grade]) @@unique([userId, grade])
} }
model MatchResult {
id String @id @default(cuid())
userId String
grade Grade
won Boolean
eloChange Int
eloAfter Int
timeSpentMs Int?
playedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}
+18 -8
View File
@@ -3,19 +3,29 @@ const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const db = require('../db'); const db = require('../db');
async function generateUniqueUsername(base) { function deriveBase(email) {
const candidate = base.toLowerCase(); const local = email.split('@')[0];
const taken = await db.user.findFirst({ where: { username: candidate } }); const sanitized = local
if (!taken) return candidate; .replace(/\./g, '_')
.replace(/[^a-zA-Z0-9_]/g, '')
.slice(0, 15)
.toLowerCase();
return sanitized || 'user';
}
async function generateUniqueUsername(email) {
const base = deriveBase(email);
const taken = await db.user.findFirst({ where: { username: base } });
if (!taken) return base;
let attempts = 0; let attempts = 0;
while (attempts < 20) { while (attempts < 20) {
const suffix = String(Math.floor(1000 + Math.random() * 9000)); const suffix = String(Math.floor(1000 + Math.random() * 9000));
const username = `${candidate}${suffix}`; const username = `${base}_${suffix}`;
const collision = await db.user.findFirst({ where: { username } }); const collision = await db.user.findFirst({ where: { username } });
if (!collision) return username; if (!collision) return username;
attempts++; attempts++;
} }
return `${candidate}${Date.now()}`; return `${base}_${Date.now()}`;
} }
passport.use( passport.use(
@@ -31,7 +41,7 @@ passport.use(
let user = await db.user.findUnique({ where: { googleId: profile.id } }); let user = await db.user.findUnique({ where: { googleId: profile.id } });
if (!user) { if (!user) {
const username = await generateUniqueUsername(email.split('@')[0]); const username = await generateUniqueUsername(email);
user = await db.user.create({ user = await db.user.create({
data: { data: {
googleId: profile.id, googleId: profile.id,
@@ -51,7 +61,7 @@ passport.use(
}, },
}); });
if (!user.username) { if (!user.username) {
const username = await generateUniqueUsername((user.email ?? '').split('@')[0]); const username = await generateUniqueUsername(user.email ?? '');
user = await db.user.update({ where: { id: user.id }, data: { username } }); user = await db.user.update({ where: { id: user.id }, data: { username } });
} }
} }
+199 -76
View File
@@ -2,6 +2,8 @@ require('dotenv').config();
const crypto = require('crypto'); const crypto = require('crypto');
const express = require('express'); const express = require('express');
const path = require('path');
const fs = require('fs');
const cors = require('cors'); const cors = require('cors');
const passport = require('./auth/google'); const passport = require('./auth/google');
const authMiddleware = require('./middleware/auth'); const authMiddleware = require('./middleware/auth');
@@ -14,8 +16,6 @@ const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/; const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
const STAGE_SIZE = 11; const STAGE_SIZE = 11;
const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions 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 ────────────────────────────────────── // ── In-memory matchmaking ──────────────────────────────────────
const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } } const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } }
@@ -43,38 +43,61 @@ async function completeGame(game, forcedWinnerId = null) {
if (game.completing || game.status !== 'active') return; if (game.completing || game.status !== 'active') return;
game.completing = true; game.completing = true;
if (game.abandonTimeout) {
clearTimeout(game.abandonTimeout);
game.abandonTimeout = null;
}
const players = Object.keys(game.players); const players = Object.keys(game.players);
const winnerId = forcedWinnerId ?? computeGameResult(game); const winnerId = forcedWinnerId ?? computeGameResult(game);
game.winner = winnerId; game.winner = winnerId;
game.completedAt = Date.now(); game.completedAt = Date.now();
// Snapshot both ELOs before any writes
const eloSnapshot = {};
for (const uid of players) {
const entry = await db.leaderboardEntry.findUnique({
where: { userId_grade: { userId: uid, grade: game.grade } },
});
eloSnapshot[uid] = entry?.elo ?? DEFAULT_ELO;
}
const loserId = players.find(id => id !== winnerId);
const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]);
for (const userId of players) { for (const userId of players) {
const p = game.players[userId]; const p = game.players[userId];
const won = userId === winnerId; const isWinner = userId === winnerId;
const change = isWinner ? winnerChange : loserChange;
const currentEntry = await db.leaderboardEntry.findUnique({ const newElo = eloSnapshot[userId] + change;
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, { await upsertLeaderboardEntry(userId, game.grade, {
elo: result.newElo, elo: newElo,
bestTime: p.timeSpentMs ?? undefined, bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined,
correctAnswers: p.correctAnswers, correctAnswers: p.correctAnswers,
}); });
p.eloChange = result.change; p.eloChange = change;
p.newElo = result.newElo; p.newElo = newElo;
delete playerGames[userId]; delete playerGames[userId];
} }
// Record per-player match results for dashboard history
await Promise.all(players.map(userId => {
const p = game.players[userId];
return db.matchResult.create({
data: {
userId,
grade: game.grade,
won: userId === winnerId,
eloChange: p.eloChange,
eloAfter: p.newElo,
timeSpentMs: p.timeSpentMs ?? null,
},
});
}));
// Set completed only after ELO is written so polls see final eloChange // Set completed only after ELO is written so polls see final eloChange
game.status = 'completed'; game.status = 'completed';
@@ -83,56 +106,13 @@ async function completeGame(game, forcedWinnerId = null) {
}, 30000); }, 30000);
} }
// Disconnect detection — runs every 3s // Cleanup stale queue entries (30s no heartbeat) and old completed games every 30s
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(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [userId, q] of Object.entries(queue)) { for (const [userId, q] of Object.entries(queue)) {
if (now - q.joinedAt > 300000) delete queue[userId]; if (now - q.lastSeen > 30000) delete queue[userId];
} }
for (const [id, game] of Object.entries(activeGames)) { 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) { if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) {
delete activeGames[id]; delete activeGames[id];
} }
@@ -141,10 +121,14 @@ setInterval(() => {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json({ limit: '3mb' }));
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true })); app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(passport.initialize()); app.use(passport.initialize());
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
app.use('/uploads', express.static(UPLOADS_DIR));
app.get('/auth/google', passport.authenticate('google', { app.get('/auth/google', passport.authenticate('google', {
scope: ['profile', 'email'], scope: ['profile', 'email'],
session: false, session: false,
@@ -177,14 +161,15 @@ app.get('/auth/me', authMiddleware, async (req, res) => {
}); });
app.get('/leaderboard', async (req, res) => { app.get('/leaderboard', async (req, res) => {
const { grade } = req.query; const { grade, type = 'time' } = req.query;
if (!VALID_GRADES.has(grade)) { if (!VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
} }
const byElo = type === 'elo';
try { try {
const entries = await db.leaderboardEntry.findMany({ const entries = await db.leaderboardEntry.findMany({
where: { grade, bestTime: { not: null } }, where: byElo ? { grade, gamesPlayed: { gt: 0 } } : { grade, bestTime: { not: null } },
orderBy: { bestTime: 'asc' }, orderBy: byElo ? { elo: 'desc' } : { bestTime: 'asc' },
take: 10, take: 10,
include: { include: {
user: { select: { displayName: true, username: true, avatarUrl: true } }, user: { select: { displayName: true, username: true, avatarUrl: true } },
@@ -196,6 +181,7 @@ app.get('/leaderboard', async (req, res) => {
username: e.user.username, username: e.user.username,
avatarUrl: e.user.avatarUrl, avatarUrl: e.user.avatarUrl,
bestTime: e.bestTime, bestTime: e.bestTime,
elo: e.elo,
}))); })));
} catch { } catch {
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@@ -206,11 +192,22 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
try { try {
const entries = await Promise.all( const entries = await Promise.all(
['G3', 'G6', 'G9'].map(async grade => { ['G3', 'G6', 'G9'].map(async grade => {
const [eloResult, timeResult] = await Promise.all([ const [eloResult, timeResult, totalPlayers, recentMatches] = await Promise.all([
getUserRank(req.user.userId, grade, 'elo'), getUserRank(req.user.userId, grade, 'elo'),
getUserRank(req.user.userId, grade, 'bestTime'), getUserRank(req.user.userId, grade, 'bestTime'),
db.leaderboardEntry.count({ where: { grade, gamesPlayed: { gt: 0 } } }),
db.matchResult.findMany({
where: { userId: req.user.userId, grade },
orderBy: { playedAt: 'desc' },
take: 20,
}),
]); ]);
const entry = eloResult?.entry ?? null; const entry = eloResult?.entry ?? null;
let winStreak = 0;
for (const m of recentMatches) {
if (m.won) winStreak++;
else break;
}
return { return {
grade, grade,
elo: entry?.elo ?? 800, elo: entry?.elo ?? 800,
@@ -219,6 +216,8 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
totalCorrect: entry?.totalCorrect ?? 0, totalCorrect: entry?.totalCorrect ?? 0,
eloRank: eloResult?.rank ?? null, eloRank: eloResult?.rank ?? null,
timeRank: timeResult?.rank ?? null, timeRank: timeResult?.rank ?? null,
totalPlayers,
winStreak,
}; };
}) })
); );
@@ -228,6 +227,23 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
} }
}); });
app.get('/me/recent-matches', authMiddleware, async (req, res) => {
const { grade } = req.query;
if (grade && !VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'invalid grade' });
}
try {
const matches = await db.matchResult.findMany({
where: { userId: req.user.userId, ...(grade ? { grade } : {}) },
orderBy: { playedAt: 'desc' },
take: 5,
});
res.json(matches);
} catch {
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/game/complete', authMiddleware, async (req, res) => { app.post('/game/complete', authMiddleware, async (req, res) => {
const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body; const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body;
@@ -270,7 +286,7 @@ app.put('/me/username', authMiddleware, async (req, res) => {
const lower = username.toLowerCase(); const lower = username.toLowerCase();
try { try {
const conflict = await db.user.findFirst({ const conflict = await db.user.findFirst({
where: { username: lower, NOT: { id: req.user.userId } }, where: { username: lower, id: { not: req.user.userId } },
}); });
if (conflict) return res.status(409).json({ error: 'taken' }); if (conflict) return res.status(409).json({ error: 'taken' });
const updated = await db.user.update({ const updated = await db.user.update({
@@ -278,11 +294,40 @@ app.put('/me/username', authMiddleware, async (req, res) => {
data: { username: lower }, data: { username: lower },
}); });
res.json({ username: updated.username }); res.json({ username: updated.username });
} catch { } catch (err) {
console.error('[me/username]', err);
if (err.code === 'P2002') return res.status(409).json({ error: 'taken' });
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
const VALID_IMAGE_RE = /^data:image\/(png|jpeg|jpg|gif|webp);base64,/;
app.post('/me/avatar', authMiddleware, async (req, res) => {
const { image } = req.body;
if (!image || typeof image !== 'string' || !VALID_IMAGE_RE.test(image)) {
return res.status(400).json({ error: 'Invalid image data' });
}
const base64Data = image.replace(/^data:image\/\w+;base64,/, '');
const buf = Buffer.from(base64Data, 'base64');
if (buf.length > 2 * 1024 * 1024) {
return res.status(400).json({ error: 'Image too large (max 2MB)' });
}
const mime = image.match(/^data:(image\/\w+);/)[1];
const ext = mime.split('/')[1].replace('jpeg', 'jpg');
const filename = `avatar_${req.user.userId}_${Date.now()}.${ext}`;
const filepath = path.join(UPLOADS_DIR, filename);
try {
await fs.promises.writeFile(filepath, buf);
const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`;
await db.user.update({ where: { id: req.user.userId }, data: { avatarUrl } });
res.json({ avatarUrl });
} catch (err) {
console.error('[me/avatar]', err);
res.status(500).json({ error: 'Failed to save image' });
}
});
app.get('/questions', authMiddleware, (req, res) => { app.get('/questions', authMiddleware, (req, res) => {
const { grade } = req.query; const { grade } = req.query;
if (!VALID_GRADES.has(grade)) { if (!VALID_GRADES.has(grade)) {
@@ -409,7 +454,7 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
return res.json({ status: 'matched', gameId }); return res.json({ status: 'matched', gameId });
} }
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl }; queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() };
res.json({ status: 'waiting' }); res.json({ status: 'waiting' });
} catch (err) { } catch (err) {
console.error('[queue/join] error:', err); console.error('[queue/join] error:', err);
@@ -417,8 +462,29 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
} }
}); });
app.delete('/queue/leave', authMiddleware, (req, res) => { app.delete('/queue/leave', authMiddleware, async (req, res) => {
delete queue[req.user.userId]; const userId = req.user.userId;
delete queue[userId];
// If the player was matched into a game during the cancel window, forfeit it
// immediately so the opponent isn't stuck waiting for a ghost.
const gameId = playerGames[userId];
if (gameId) {
const game = activeGames[gameId];
if (game && game.status === 'active') {
const player = game.players[userId];
if (player && !player.finished) {
const opponentId = Object.keys(game.players).find(id => id !== userId);
// Leave timeSpentMs null so completeGame doesn't record a bestTime for
// someone who cancelled before the game even started.
player.correctAnswers = 0;
player.finished = true;
player.finishedAt = Date.now();
await completeGame(game, opponentId);
}
}
}
res.json({ success: true }); res.json({ success: true });
}); });
@@ -441,6 +507,8 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
return res.json({ status: 'not_in_queue' }); return res.json({ status: 'not_in_queue' });
} }
game.players[req.user.userId].lastSeen = Date.now();
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
const opponent = game.players[opponentId]; const opponent = game.players[opponentId];
@@ -464,6 +532,7 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
} }
if (queue[req.user.userId]) { if (queue[req.user.userId]) {
queue[req.user.userId].lastSeen = Date.now();
return res.json({ status: 'waiting' }); return res.json({ status: 'waiting' });
} }
@@ -472,6 +541,36 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
// ── Game management ──────────────────────────────────────────── // ── Game management ────────────────────────────────────────────
app.post('/game/forfeit', authMiddleware, async (req, res) => {
const { gameId } = req.body;
if (!gameId) return res.status(400).json({ error: 'gameId required' });
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 });
const elapsed = Date.now() - game.startedAt;
player.timeSpentMs = Math.max(MIN_GAME_MS, elapsed + 5000);
player.correctAnswers = 0;
player.finished = true;
player.finishedAt = Date.now();
game.abandonTimeout = null;
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
completeGame(game, opponentId);
res.json({ success: true });
});
app.post('/game/progress', authMiddleware, (req, res) => { app.post('/game/progress', authMiddleware, (req, res) => {
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body; const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
const game = activeGames[gameId]; const game = activeGames[gameId];
@@ -485,10 +584,10 @@ app.post('/game/progress', authMiddleware, (req, res) => {
return res.status(403).json({ error: 'Not a participant in this game' }); 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(); player.lastSeen = Date.now();
if (player.finished) return res.json({ success: true }); // idempotent
if (currentQuestion !== undefined) { if (currentQuestion !== undefined) {
player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion)))); player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion))));
} }
@@ -503,9 +602,18 @@ app.post('/game/progress', authMiddleware, (req, res) => {
player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000)); player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000));
player.finished = true; player.finished = true;
player.finishedAt = Date.now(); player.finishedAt = Date.now();
player.completedQuiz = true;
const winner = computeGameResult(game); const winner = computeGameResult(game);
if (winner) completeGame(game); if (winner) {
completeGame(game);
} else {
// Opponent hasn't finished — award win to this player after 5 minutes if game still active
const finishedUserId = req.user.userId;
game.abandonTimeout = setTimeout(() => {
if (game.status === 'active') completeGame(game, finishedUserId);
}, 5 * 60 * 1000);
}
} }
res.json({ success: true }); res.json({ success: true });
@@ -527,12 +635,27 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
const opponent = game.players[opponentId]; const opponent = game.players[opponentId];
// Detect opponent disconnect — award win after 20s of inactivity
const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000;
if (game.status === 'active' && !player.finished && opponentInactive) {
game.disconnectedPlayer = opponentId;
await completeGame(game, req.user.userId);
}
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } }); const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName; const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
const opponentDced = game.disconnectedPlayer === opponentId;
const youDced = game.disconnectedPlayer === req.user.userId;
// Show DC badge early (8s inactivity) before the game officially ends at 20s
const opponentConnected = !opponentDced && (!opponent.lastSeen || Date.now() - opponent.lastSeen < 8000);
res.json({ res.json({
status: game.status, status: game.status,
youWon: game.winner !== null && game.winner === req.user.userId, youWon: game.winner !== null && game.winner === req.user.userId,
opponentDced,
youDced,
opponentConnected,
players: { players: {
you: { you: {
currentQuestion: player.currentQuestion, currentQuestion: player.currentQuestion,
+6 -6
View File
@@ -4,13 +4,13 @@ const DIFF_STEP = 30;
const MAX_CHANGE = 30; const MAX_CHANGE = 30;
const MIN_CHANGE = 1; const MIN_CHANGE = 1;
// Custom ELO: base ±10, adjusted ±1 for every 30-point gap, clamped [1, 30]. // Calculate ELO change for a completed match.
// Underdog who wins gains more; favourite who wins gains less. // Magnitude is derived once from the winner's perspective so both sides
function calculateElo(yourElo, opponentElo, won) { // move by the same amount (winner gains X, loser loses X).
const diff = opponentElo - yourElo; function calculateElo(winnerElo, loserElo) {
const diff = loserElo - winnerElo; // positive = underdog won, negative = favourite won
const magnitude = Math.max(MIN_CHANGE, Math.min(MAX_CHANGE, BASE_CHANGE + Math.floor(diff / DIFF_STEP))); const magnitude = Math.max(MIN_CHANGE, Math.min(MAX_CHANGE, BASE_CHANGE + Math.floor(diff / DIFF_STEP)));
const change = won ? magnitude : -magnitude; return { winnerChange: magnitude, loserChange: -magnitude };
return { newElo: yourElo + change, change };
} }
module.exports = { calculateElo, DEFAULT_ELO }; module.exports = { calculateElo, DEFAULT_ELO };
+1 -1
View File
@@ -17,7 +17,7 @@ async function upsertLeaderboardEntry(userId, grade, { elo, bestTime, correctAns
}, },
update: { update: {
...(elo !== undefined && { elo }), ...(elo !== undefined && { elo }),
...(bestTime !== undefined && existing?.bestTime == null || bestTime < (existing?.bestTime ?? Infinity) ...(bestTime !== undefined && (existing?.bestTime == null || bestTime < existing.bestTime)
? { bestTime } ? { bestTime }
: {}), : {}),
gamesPlayed: { increment: 1 }, gamesPlayed: { increment: 1 },
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB