+
)
}
-export default Footer
\ No newline at end of file
+export default Footer
diff --git a/apps/frontend/src/components/HeroSection.tsx b/apps/frontend/src/components/HeroSection.tsx
index 9b6d245..93a6224 100644
--- a/apps/frontend/src/components/HeroSection.tsx
+++ b/apps/frontend/src/components/HeroSection.tsx
@@ -27,13 +27,13 @@ function HeroSection() {
return (
-
+
-
+
{/* Badge */}
-
+
diff --git a/apps/frontend/src/components/Navbar.tsx b/apps/frontend/src/components/Navbar.tsx
index 2537ab5..9d6650e 100644
--- a/apps/frontend/src/components/Navbar.tsx
+++ b/apps/frontend/src/components/Navbar.tsx
@@ -1,4 +1,6 @@
+import { useEffect, useState } from "react"
import { BsGithub } from "react-icons/bs"
+import { HiMenuAlt3, HiX } from "react-icons/hi"
import { Link } from "react-router-dom"
import { useAuthStore } from "../store/authStore"
@@ -6,65 +8,142 @@ function Navbar() {
const { user } = useAuthStore();
+ const [menuOpen, setMenuOpen] = useState(false);
+ // Transparent over the hero, solid once the page moves under it, so the
+ // bar reads as part of the page rather than a slab sitting on top of it.
+ const [scrolled, setScrolled] = useState(false);
- return (
-
+ // A menu left open while the page scrolls away behind it is disorienting.
+ useEffect(() => {
+ if (!menuOpen) return;
+ const close = () => setMenuOpen(false);
+ window.addEventListener('scroll', close, { passive: true });
+ return () => window.removeEventListener('scroll', close);
+ }, [menuOpen]);
- {/* Right Section */}
-
);
diff --git a/apps/frontend/src/screens/LiveQuiz.tsx b/apps/frontend/src/screens/LiveQuiz.tsx
index 520d9ce..4ad93d3 100644
--- a/apps/frontend/src/screens/LiveQuiz.tsx
+++ b/apps/frontend/src/screens/LiveQuiz.tsx
@@ -77,18 +77,29 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) {
// because the leader is sized differently from the trail, so this tracks
// the viewport directly. Mobile keeps the original sizes; a laptop or a
// projector has the room for something readable across a hall.
- const [isWideScreen, setIsWideScreen] = useState(
- typeof window !== 'undefined' && window.matchMedia('(min-width: 640px)').matches
- );
+ // Three steps rather than two: a projector or a large monitor has the room
+ // for avatars that read across a hall, and the two-step version still
+ // looked small on anything above a laptop.
+ const [screenTier, setScreenTier] = useState<'sm' | 'md' | 'lg'>(() => {
+ if (typeof window === 'undefined') return 'sm';
+ if (window.matchMedia('(min-width: 1024px)').matches) return 'lg';
+ if (window.matchMedia('(min-width: 640px)').matches) return 'md';
+ return 'sm';
+ });
useEffect(() => {
- const mq = window.matchMedia('(min-width: 640px)');
- const onChange = (e: MediaQueryListEvent) => setIsWideScreen(e.matches);
- mq.addEventListener('change', onChange);
- return () => mq.removeEventListener('change', onChange);
+ const wide = window.matchMedia('(min-width: 1024px)');
+ const mid = window.matchMedia('(min-width: 640px)');
+ const update = () => setScreenTier(wide.matches ? 'lg' : mid.matches ? 'md' : 'sm');
+ wide.addEventListener('change', update);
+ mid.addEventListener('change', update);
+ return () => {
+ wide.removeEventListener('change', update);
+ mid.removeEventListener('change', update);
+ };
}, []);
- const avatarLead = isWideScreen ? 52 : 30;
- const avatarTrail = isWideScreen ? 42 : 24;
- const avatarOverlap = isWideScreen ? -14 : -9;
+ const avatarLead = screenTier === 'lg' ? 76 : screenTier === 'md' ? 62 : 30;
+ const avatarTrail = screenTier === 'lg' ? 62 : screenTier === 'md' ? 50 : 24;
+ const avatarOverlap = screenTier === 'lg' ? -20 : screenTier === 'md' ? -16 : -9;
const [selectedAnswer, setSelectedAnswer] = useState(null);
const [questionStartTime, setQuestionStartTime] = useState(0);
@@ -617,16 +628,16 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) {
return (
-
+
{allIn ? 'Everyone has answered' : 'Answers in'}
-
+ {answered} / {total}
@@ -635,7 +646,7 @@ function LiveQuiz({ isOrganizer = false }: LiveQuizProps) {
{/* Avatars of whoever answered most recently, riding the
head of the bar. Newest is leftmost and largest, so an
arrival is visible from across a room. */}
-
+
-
+
{!allIn && (
-
+
{total - answered} still answering
)}
diff --git a/apps/http-server/src/controllers/quizController.ts b/apps/http-server/src/controllers/quizController.ts
index 6c9fcb5..624b486 100644
--- a/apps/http-server/src/controllers/quizController.ts
+++ b/apps/http-server/src/controllers/quizController.ts
@@ -70,16 +70,48 @@ export const getAllQuizesController = async (req: Request, res: Response) => {
where: {
userId: req.userId
}
- }
+ },
+ // Total players in the session, so a past quiz can say
+ // "12th of 300" rather than just a score with no context.
+ _count: { select: { participants: true } }
},
orderBy: { createdAt: 'desc' }
});
+ // Rank is a position within a session, which SQL will not give us from
+ // the query above. Counting how many beat each player is one query for
+ // the whole dashboard rather than one per quiz.
+ const myParticipantIds = participatedQuizes
+ .map((s) => s.participants[0]?.id)
+ .filter((id): id is string => Boolean(id));
+
+ const ranks = new Map();
+ if (myParticipantIds.length > 0) {
+ const rows = await prisma.$queryRaw<{ id: string; rank: number }[]>`
+ SELECT me.id,
+ (
+ SELECT count(*) + 1
+ FROM "Participant" other
+ WHERE other."sessionId" = me."sessionId"
+ AND other.score > me.score
+ )::int AS rank
+ FROM "Participant" me
+ WHERE me.id = ANY(${myParticipantIds})
+ `;
+ for (const row of rows) ranks.set(row.id, row.rank);
+ }
+
+ const participatedWithRank = participatedQuizes.map((session) => ({
+ ...session,
+ totalParticipants: session._count.participants,
+ myRank: ranks.get(session.participants[0]?.id ?? '') ?? null,
+ }));
+
return res.status(200).json({
organizedQuizes,
- participated: participatedQuizes
+ participated: participatedWithRank
})
} catch (error) {
diff --git a/apps/http-server/src/middlewares/authmiddleware.ts b/apps/http-server/src/middlewares/authmiddleware.ts
index 790dca4..485a13c 100644
--- a/apps/http-server/src/middlewares/authmiddleware.ts
+++ b/apps/http-server/src/middlewares/authmiddleware.ts
@@ -26,4 +26,34 @@ export const authMiddleware = async (req: Request, res: Response, next: NextFunc
console.log("Error in authentication", error);
return errorResponse(res, 500, "Internal Server Error...")
}
-}
\ No newline at end of file
+}
+
+/**
+ * Attach req.userId when a valid token is present, and carry on when it is not.
+ *
+ * Joining a quiz is open to anyone — no account needed, which is the point of
+ * a join code — so the route cannot require auth. But it still needs to know
+ * who a signed-in participant is: without that the Participant row is written
+ * with userId null and the player never sees the quiz in their history,
+ * because nothing links the two.
+ *
+ * A bad or expired token is treated as no token rather than an error. Someone
+ * with a stale token in their browser should still be able to play.
+ */
+export const optionalAuthMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
+ const authHeader = req.headers["authorization"];
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) return next();
+
+ const token = authHeader.split(' ')[1];
+ if (!token) return next();
+
+ try {
+ const decoded = jwt.verify(token, process.env.JWT_SECRET || "secret") as { userId: string };
+ req.userId = decoded.userId;
+ } catch {
+ // Expired or malformed: continue as an anonymous participant.
+ }
+
+ next();
+}
diff --git a/apps/http-server/src/routes/session.ts b/apps/http-server/src/routes/session.ts
index d8708c8..7634512 100644
--- a/apps/http-server/src/routes/session.ts
+++ b/apps/http-server/src/routes/session.ts
@@ -1,10 +1,14 @@
import { Router } from "express";
import { getLeaderboardController, joinQuizController } from "../controllers/sessionController";
+import { optionalAuthMiddleware } from "../middlewares/authmiddleware";
const router = Router();
// join quiz
-router.post("/join", joinQuizController)
+// Optional auth, not required: anyone with a code can play, but a signed-in
+// participant must be linked to their account or the quiz never shows up in
+// their history.
+router.post("/join", optionalAuthMiddleware, joinQuizController)
// get leaderboard
router.get('/:sessionId/leaderboard', getLeaderboardController);
diff --git a/apps/ws-server/src/utils/handleMessages.ts b/apps/ws-server/src/utils/handleMessages.ts
index 1b5104a..f401638 100644
--- a/apps/ws-server/src/utils/handleMessages.ts
+++ b/apps/ws-server/src/utils/handleMessages.ts
@@ -32,6 +32,79 @@ import { enqueueAnswer, countAnswered, drainSession } from "./answerQueue.js";
const MAX_POINTS = 10;
+/**
+ * Rate-limited progress updates.
+ *
+ * One broadcast per answer is O(answers x clients): at 750 participants that
+ * is roughly half a million socket writes in the few seconds a question takes
+ * to fill, on the one thread that also has to run the countdown. The timer
+ * skipped seconds and the results arrived late because of it.
+ *
+ * Coalescing to one update per interval keeps the bar smooth — it animates
+ * between values client-side — at a fraction of the cost. The newest payload
+ * always wins, and a trailing send guarantees the final count is not the one
+ * that got dropped.
+ */
+const PROGRESS_INTERVAL_MS = 150;
+
+interface ProgressPayload {
+ questionId: string;
+ answered: number;
+ total: number;
+ participantId?: string;
+}
+
+const progressPending = new Map();
+const progressTimers = new Map();
+const progressLastSent = new Map();
+
+function queueProgressBroadcast(sessionId: string, payload: ProgressPayload) {
+ progressPending.set(sessionId, payload);
+
+ const now = Date.now();
+ const last = progressLastSent.get(sessionId) ?? 0;
+ const elapsed = now - last;
+
+ // Enough time has passed: send immediately, so the first answer and a
+ // slow trickle both show up at once rather than waiting out the interval.
+ if (elapsed >= PROGRESS_INTERVAL_MS) {
+ flushProgress(sessionId);
+ return;
+ }
+
+ // Otherwise let the already-scheduled trailing send carry it.
+ if (progressTimers.has(sessionId)) return;
+
+ progressTimers.set(
+ sessionId,
+ setTimeout(() => {
+ progressTimers.delete(sessionId);
+ flushProgress(sessionId);
+ }, PROGRESS_INTERVAL_MS - elapsed),
+ );
+}
+
+function flushProgress(sessionId: string) {
+ const payload = progressPending.get(sessionId);
+ if (!payload) return;
+
+ progressPending.delete(sessionId);
+ progressLastSent.set(sessionId, Date.now());
+ broadcastToSession(sessionId, 'quiz:answer-progress', payload);
+}
+
+/** Drop a session's progress state once its quiz is over. */
+function clearProgress(sessionId: string) {
+ const timer = progressTimers.get(sessionId);
+ if (timer) {
+ clearTimeout(timer);
+ progressTimers.delete(sessionId);
+ }
+ progressPending.delete(sessionId);
+ progressLastSent.delete(sessionId);
+}
+
+
/**
@@ -265,6 +338,7 @@ export const handleMessage = async (client: Client, data: any) => {
await drainSession(sessionId);
await endQuizSession(sessionId);
await clearSessionCache(sessionId);
+ clearProgress(sessionId);
broadcastToSession(sessionId, 'quiz:ended');
return;
};
@@ -388,14 +462,19 @@ export const handleMessage = async (client: Client, data: any) => {
connected.map((c) => c.participantId as string),
);
- // Progress for the room, from the counts already gathered
- // above: no extra query. It also explains the early finish —
- // without it a question ending before the clock looks like a
- // glitch rather than "everyone has answered".
- // participantId rides along so the room can show whose answer
- // just landed. Avatars are derived from the id on the client,
- // so this costs one field rather than a lookup.
- broadcastToSession(sessionId, 'quiz:answer-progress', {
+ // Progress for the room, coalesced rather than sent per
+ // answer. Broadcasting on every submission meant 737 answers
+ // against 738 sockets — about half a million ws.send() calls
+ // in the four seconds a question takes to fill — which
+ // saturated the event loop: the one-second countdown could
+ // not run on schedule and visibly skipped (22 straight to
+ // 16), and the results that follow queued behind the
+ // backlog, reaching the host seconds late.
+ //
+ // At most one update every 150ms carries the same
+ // information: the bar is animated client-side anyway, and
+ // nobody can read 737 discrete updates.
+ queueProgressBroadcast(sessionId, {
questionId,
answered,
total: connected.length,
@@ -403,6 +482,12 @@ export const handleMessage = async (client: Client, data: any) => {
});
if (answered >= connected.length) {
+ // Send the final n/n now rather than letting the trailing
+ // timer carry it: the question is about to close, and a
+ // bar that never visibly fills is what made the early
+ // finish look arbitrary.
+ flushProgress(sessionId);
+
// Index of the question just answered, taken from the
// question list rather than the cached session: the
// session cache is not invalidated when the organizer
@@ -432,6 +517,7 @@ export const handleMessage = async (client: Client, data: any) => {
// read, or the last question's scores are missing from the board
// everyone finishes on.
await drainSession(sessionId);
+ clearProgress(sessionId);
await invalidateLeaderboard(sessionId);
// Read the leaderboard before the caches are dropped: both
diff --git a/apps/ws-server/src/utils/timeManager.ts b/apps/ws-server/src/utils/timeManager.ts
index 160b526..19128b8 100644
--- a/apps/ws-server/src/utils/timeManager.ts
+++ b/apps/ws-server/src/utils/timeManager.ts
@@ -189,8 +189,14 @@ export async function startQuestionTimer(
broadcastToSession(sessionId, 'quiz:timer-tick', { timeLeft });
+ // Absolute end time, so a tick that runs late reports the time that has
+ // actually passed. Decrementing a counter instead meant a delayed tick
+ // silently lost that second: under load the countdown jumped 22 -> 16 and
+ // then finished early by however much it had skipped.
+ const endsAt = Date.now() + question.timeLimit * 1_000;
+
const timer = setInterval(async () => {
- timeLeft--;
+ timeLeft = Math.max(0, Math.ceil((endsAt - Date.now()) / 1_000));
broadcastToSession(sessionId, 'quiz:timer-tick', { timeLeft });
if (timeLeft <= 0) {