From ea33b7fcc8c3117592984efbf09bd7d324058b4b Mon Sep 17 00:00:00 2001 From: lila Date: Sat, 11 Apr 2026 12:56:03 +0200 Subject: [PATCH] feat(web): add minimal playable quiz at /play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/api/src/services/gameService.ts | 1 + apps/web/src/routeTree.gen.ts | 24 +++++- apps/web/src/routes/play.tsx | 123 +++++++++++++++++++++++++++ apps/web/vite.config.ts | 1 + documentation/api-development.md | 4 + packages/shared/src/schemas/game.ts | 1 + 6 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/routes/play.tsx diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index 7f56cdd..b5e9f7b 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -94,5 +94,6 @@ export const evaluateAnswer = async ( questionId: submission.questionId, isCorrect: submission.selectedOptionId === correctOptionId, correctOptionId, + selectedOptionId: submission.selectedOptionId, }; }; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 59499d9..17e2c55 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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. import { Route as rootRouteImport } from './routes/__root' +import { Route as PlayRouteImport } from './routes/play' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +const PlayRoute = PlayRouteImport.update({ + id: '/play', + path: '/play', + getParentRoute: () => rootRouteImport, +} as any) const AboutRoute = AboutRouteImport.update({ id: '/about', path: '/about', @@ -26,31 +32,42 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute + '/play': typeof PlayRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute + '/play': typeof PlayRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/about': typeof AboutRoute + '/play': typeof PlayRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' + fullPaths: '/' | '/about' | '/play' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' - id: '__root__' | '/' | '/about' + to: '/' | '/about' | '/play' + id: '__root__' | '/' | '/about' | '/play' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute + PlayRoute: typeof PlayRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/play': { + id: '/play' + path: '/play' + fullPath: '/play' + preLoaderRoute: typeof PlayRouteImport + parentRoute: typeof rootRouteImport + } '/about': { id: '/about' path: '/about' @@ -71,6 +88,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, + PlayRoute: PlayRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx new file mode 100644 index 0000000..e4100be --- /dev/null +++ b/apps/web/src/routes/play.tsx @@ -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(null); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [results, setResults] = useState([]); + const [currentResult, setCurrentResult] = useState(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

Loading...

; + } + + // Phase: finished + if (currentQuestionIndex >= gameSession.questions.length) { + const score = results.filter((r) => r.isCorrect).length; + return ( +
+

+ {score} / {results.length} +

+

Game over!

+
+ ); + } + + // Phase: playing + const question = gameSession.questions[currentQuestionIndex]!; + + return ( +
+

+ Question {currentQuestionIndex + 1} / {gameSession.questions.length} +

+

{question.prompt}

+ {question.gloss &&

{question.gloss}

} +
+ {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 ( + + ); + })} +
+ {currentResult && ( + + )} +
+ ); +} + +export const Route = createFileRoute("/play")({ component: Play }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 06d2167..a6e9dbd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,4 +10,5 @@ export default defineConfig({ react(), tailwindcss(), ], + server: { proxy: { "/api": "http://localhost:3000" } }, }); diff --git a/documentation/api-development.md b/documentation/api-development.md index cdb93fb..a07f741 100644 --- a/documentation/api-development.md +++ b/documentation/api-development.md @@ -298,3 +298,7 @@ Required before: implementing the double join for source language prompt. `buildSession()`. - **Session statefulness:** game loop is currently stateless (fetch all questions upfront). 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. diff --git a/packages/shared/src/schemas/game.ts b/packages/shared/src/schemas/game.ts index 420b9ee..2a32a24 100644 --- a/packages/shared/src/schemas/game.ts +++ b/packages/shared/src/schemas/game.ts @@ -52,6 +52,7 @@ export const AnswerResultSchema = z.object({ questionId: z.uuid(), isCorrect: z.boolean(), correctOptionId: z.number().int().min(0).max(3), + selectedOptionId: z.number().int().min(0).max(3), }); export type AnswerResult = z.infer;