feat(web): add minimal playable quiz at /play

- Add Vite proxy for /api → localhost:3000 (no CORS needed in dev)
- Create /play route with hardcoded game settings (en→it, nouns, easy)
- Three-phase state machine: loading → playing → finished
- Show prompt, optional gloss, and 4 answer buttons per question
- Submit answers to /api/v1/game/answer, show correct/wrong feedback
- Manual Next button to advance after answering
- Score screen on completion
- Add selectedOptionId to AnswerResult schema (discovered during
  frontend work that the result needs to be self-contained for
  rendering feedback without separate client state)

Intentionally unstyled — component extraction and polish come next.
This commit is contained in:
lila 2026-04-11 12:56:03 +02:00
parent 075a691849
commit ea33b7fcc8
6 changed files with 151 additions and 3 deletions

View file

@ -94,5 +94,6 @@ export const evaluateAnswer = async (
questionId: submission.questionId, questionId: submission.questionId,
isCorrect: submission.selectedOptionId === correctOptionId, isCorrect: submission.selectedOptionId === correctOptionId,
correctOptionId, correctOptionId,
selectedOptionId: submission.selectedOptionId,
}; };
}; };

View file

@ -9,9 +9,15 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as PlayRouteImport } from './routes/play'
import { Route as AboutRouteImport } from './routes/about' import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
const PlayRoute = PlayRouteImport.update({
id: '/play',
path: '/play',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({ const AboutRoute = AboutRouteImport.update({
id: '/about', id: '/about',
path: '/about', path: '/about',
@ -26,31 +32,42 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/play': typeof PlayRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/play': typeof PlayRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/play': typeof PlayRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' fullPaths: '/' | '/about' | '/play'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' to: '/' | '/about' | '/play'
id: '__root__' | '/' | '/about' id: '__root__' | '/' | '/about' | '/play'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute AboutRoute: typeof AboutRoute
PlayRoute: typeof PlayRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/play': {
id: '/play'
path: '/play'
fullPath: '/play'
preLoaderRoute: typeof PlayRouteImport
parentRoute: typeof rootRouteImport
}
'/about': { '/about': {
id: '/about' id: '/about'
path: '/about' path: '/about'
@ -71,6 +88,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AboutRoute: AboutRoute, AboutRoute: AboutRoute,
PlayRoute: PlayRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View file

@ -0,0 +1,123 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import type { GameSession, AnswerResult } from "@glossa/shared";
const GAME_SETTINGS = {
source_language: "en",
target_language: "it",
pos: "noun",
difficulty: "easy",
rounds: "3",
} as const;
function Play() {
const [gameSession, setGameSession] = useState<GameSession | null>(null);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [results, setResults] = useState<AnswerResult[]>([]);
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
useEffect(() => {
const startGame = async () => {
const response = await fetch("/api/v1/game/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(GAME_SETTINGS),
});
const data = await response.json();
setGameSession(data.data);
};
startGame();
}, []);
const handleAnswer = async (optionId: number) => {
if (!gameSession || currentResult) return;
const question = gameSession.questions[currentQuestionIndex];
if (!question) return;
const response = await fetch("/api/v1/game/answer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: gameSession.sessionId,
questionId: question.questionId,
selectedOptionId: optionId,
}),
});
const data = await response.json();
setCurrentResult(data.data);
};
const handleNext = () => {
if (!currentResult) return;
setResults((prev) => [...prev, currentResult]);
setCurrentQuestionIndex((prev) => prev + 1);
setCurrentResult(null);
};
// Phase: loading
if (!gameSession) {
return <p>Loading...</p>;
}
// Phase: finished
if (currentQuestionIndex >= gameSession.questions.length) {
const score = results.filter((r) => r.isCorrect).length;
return (
<div>
<h2>
{score} / {results.length}
</h2>
<p>Game over!</p>
</div>
);
}
// Phase: playing
const question = gameSession.questions[currentQuestionIndex]!;
return (
<div>
<p>
Question {currentQuestionIndex + 1} / {gameSession.questions.length}
</p>
<h2>{question.prompt}</h2>
{question.gloss && <p>{question.gloss}</p>}
<div>
{question.options.map((option) => {
let style = {};
if (currentResult) {
if (option.optionId === currentResult.correctOptionId) {
style = { backgroundColor: "green", color: "white" };
} else if (option.optionId === currentResult.selectedOptionId) {
style = { backgroundColor: "red", color: "white" };
}
}
return (
<button
key={option.optionId}
onClick={() => handleAnswer(option.optionId)}
disabled={!!currentResult}
style={{
display: "block",
margin: "4px 0",
padding: "8px 16px",
...style,
}}
>
{option.text}
</button>
);
})}
</div>
{currentResult && (
<button onClick={handleNext} style={{ marginTop: "16px" }}>
Next
</button>
)}
</div>
);
}
export const Route = createFileRoute("/play")({ component: Play });

View file

@ -10,4 +10,5 @@ export default defineConfig({
react(), react(),
tailwindcss(), tailwindcss(),
], ],
server: { proxy: { "/api": "http://localhost:3000" } },
}); });

View file

@ -298,3 +298,7 @@ Required before: implementing the double join for source language prompt.
`buildSession()`. `buildSession()`.
- **Session statefulness:** game loop is currently stateless (fetch all questions upfront). - **Session statefulness:** game loop is currently stateless (fetch all questions upfront).
Confirm this is still the intended MVP approach before building `buildSession()`. Confirm this is still the intended MVP approach before building `buildSession()`.
- **Glosses can leak answers:** some WordNet glosses contain the target-language
word in the definition text (e.g. "Padre" appearing in the English gloss for
"father"). Address during the post-MVP data enrichment pass — either clean the
glosses, replace them with custom definitions, or filter at the service layer.

View file

@ -52,6 +52,7 @@ export const AnswerResultSchema = z.object({
questionId: z.uuid(), questionId: z.uuid(),
isCorrect: z.boolean(), isCorrect: z.boolean(),
correctOptionId: z.number().int().min(0).max(3), correctOptionId: z.number().int().min(0).max(3),
selectedOptionId: z.number().int().min(0).max(3),
}); });
export type AnswerResult = z.infer<typeof AnswerResultSchema>; export type AnswerResult = z.infer<typeof AnswerResultSchema>;