From 792d46beb3538227e1078366131aa074e76e8db9 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Sat, 2 May 2026 01:25:19 +0200 Subject: [PATCH] wip answering --- api/src/routes/quiz.ts | 39 ++----- api/src/workflows/quiz.ts | 20 ++-- web/src/components/party/party-view.tsx | 11 +- web/src/components/party/question.tsx | 130 ++++++++++++++++++++++++ web/src/components/party/results.tsx | 42 ++++++++ web/src/components/start-party.tsx | 13 ++- web/src/components/user-info.tsx | 8 ++ 7 files changed, 218 insertions(+), 45 deletions(-) create mode 100644 web/src/components/party/question.tsx create mode 100644 web/src/components/party/results.tsx diff --git a/api/src/routes/quiz.ts b/api/src/routes/quiz.ts index e28ceb7..871584a 100644 --- a/api/src/routes/quiz.ts +++ b/api/src/routes/quiz.ts @@ -76,21 +76,19 @@ export const quizRoutes = new Elysia() .post( "/response", async ({ user, body, params, set }) => { - const existingQuiz = await db - .select({ data: party.data }) - .from(party) - .where(eq(party.id, params.partyId)) - .limit(1) - .then((rows) => rows[0]); + const party = await db.query.party.findFirst({ + where: { + id: params.partyId, + }, + }); - if (!existingQuiz) { + if (!party) { set.status = 404; return { error: "Party not found" }; } + const quizData = party.data as QuizState | null; - const quizData = ( - (existingQuiz.data ?? {}) as Record - ).quiz as QuizState | undefined; + console.log("response quiz data", party, quizData); if (!quizData || quizData.status !== "running") { set.status = 400; @@ -108,27 +106,6 @@ export const quizRoutes = new Elysia() "quiz_responses", ); - const updatedParty = await db - .select({ data: party.data }) - .from(party) - .where(eq(party.id, params.partyId)) - .limit(1) - .then((rows) => rows[0]); - - const updatedQuizData = ( - (updatedParty?.data ?? {}) as Record - ).quiz as QuizState | undefined; - - if (updatedQuizData) { - pubsub.publish( - `party:${params.partyId}`, - JSON.stringify({ - type: "quiz_state", - quiz: updatedQuizData, - }), - ); - } - return { message: "Response recorded" }; }, { diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts index 1c2025d..a015b4d 100644 --- a/api/src/workflows/quiz.ts +++ b/api/src/workflows/quiz.ts @@ -34,10 +34,10 @@ export class QuizWorkflow extends ConfiguredInstance { }; // Initialize quiz state - await this.updatePartyData(partyId, quizState); + await QuizWorkflow.updatePartyData(partyId, quizState); // Get party members to initialize scores - const members = await this.getPartyMembers(partyId); + const members = await QuizWorkflow.getPartyMembers(partyId); for (const member of members) { quizState.scores[member.userId] = 0; } @@ -45,11 +45,11 @@ export class QuizWorkflow extends ConfiguredInstance { for (let i = 0; i < TOTAL_QUESTIONS; i++) { quizState.questionIndex = i; - const question = await this.generateQuestion(i); + const question = await QuizWorkflow.generateQuestion(i); quizState.currentQuestion = question; quizState.answers = {}; - await this.updatePartyData(partyId, quizState); + await QuizWorkflow.updatePartyData(partyId, quizState); // Wait for all responses with timeout const memberIds = new Set(members.map((m) => m.userId)); const receivedPlayers = new Set(); @@ -69,7 +69,7 @@ export class QuizWorkflow extends ConfiguredInstance { selected: -1, correct: false, }; - await this.updatePartyData(partyId, quizState); + await QuizWorkflow.updatePartyData(partyId, quizState); } } break; @@ -87,17 +87,17 @@ export class QuizWorkflow extends ConfiguredInstance { (quizState.scores[response.playerId] ?? 0) + question.points; } - await this.updatePartyData(partyId, quizState); + await QuizWorkflow.updatePartyData(partyId, quizState); } } // Quiz complete quizState.status = "results"; - await this.updatePartyData(partyId, quizState); + await QuizWorkflow.updatePartyData(partyId, quizState); } @DBOS.step() - private async updatePartyData( + private static async updatePartyData( partyId: string, quizState: QuizState, ): Promise { @@ -106,7 +106,7 @@ export class QuizWorkflow extends ConfiguredInstance { } @DBOS.step() - async generateQuestion(index: number): Promise<{ + static async generateQuestion(index: number): Promise<{ text: string; options: string[]; correct: number; @@ -165,7 +165,7 @@ export class QuizWorkflow extends ConfiguredInstance { } @DBOS.step() - private async getPartyMembers( + private static async getPartyMembers( partyId: string, ): Promise<{ id: string; userId: string }[]> { return db diff --git a/web/src/components/party/party-view.tsx b/web/src/components/party/party-view.tsx index 7e8a5c1..cb622b7 100644 --- a/web/src/components/party/party-view.tsx +++ b/web/src/components/party/party-view.tsx @@ -1,6 +1,15 @@ import { useParty } from "#/hooks/use-party"; +import { Question } from "./question"; +import { Results } from "./results"; export function PartyView() { const { party } = useParty(); - if (!party) return null; + if (!party?.data) return null; + + switch (party.data.status) { + case "running": + return ; + case "results": + return ; + } } diff --git a/web/src/components/party/question.tsx b/web/src/components/party/question.tsx new file mode 100644 index 0000000..822470a --- /dev/null +++ b/web/src/components/party/question.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; + +import { Button } from "#/components/ui/button"; +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemHeader, + ItemTitle, +} from "#/components/ui/item"; +import { + Progress, + ProgressLabel, + ProgressValue, +} from "#/components/ui/progress"; +import { Section, SectionTitle } from "#/components/ui/section"; +import { Spinner } from "#/components/ui/spinner"; +import { useParty } from "#/hooks/use-party"; +import { useUser } from "#/hooks/user"; +import { client } from "#/lib/eden"; + +function formatTimeLeft(milliseconds: number) { + const clamped = Math.max(0, milliseconds); + const totalSeconds = Math.ceil(clamped / 1000); + const minutes = Math.floor(totalSeconds / 60) + .toString() + .padStart(2, "0"); + const seconds = (totalSeconds % 60).toString().padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +export function Question() { + const { party, members } = useParty(); + const { user } = useUser(); + const [now, setNow] = useState(() => Date.now()); + const [selected, setSelected] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const question = party?.data?.currentQuestion; + + useEffect(() => { + const timer = window.setInterval(() => { + setNow(Date.now()); + }, 1000); + + return () => window.clearInterval(timer); + }, []); + + if (!question) return null; + + const partyId = party.id; + const timeLeft = formatTimeLeft(question.endTimestamp - now); + const answeredCount = Object.keys(party.data.answers).length; + const hasResponded = user ? party.data.answers[user.id] != null : false; + const currentSelection = + selected ?? (user ? (party.data.answers[user.id]?.selected ?? null) : null); + const progressValue = Math.max( + 0, + Math.min( + 100, + ((question.endTimestamp - now) / + (question.endTimestamp - question.startTimestamp)) * + 100, + ), + ); + + async function handleAnswer(optionIndex: number) { + if (!partyId || hasResponded || isSubmitting) return; + setSelected(optionIndex); + setIsSubmitting(true); + try { + await client.api.party({ partyId }).quiz.response.post({ + selected: optionIndex, + }); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ Question {party.data.questionIndex + 1} + + + + {question.text} + {question.points} points + + + + + + + + {hasResponded ? "You responded" : "Waiting on your response"} + + {() => timeLeft} + + + + {answeredCount} / {members.length} responses + + + + {question.options.map((option, index) => { + const isSelected = currentSelection === index; + return ( + + + + + {index + 1}. {option} + + {isSelected && Selected} + + + + + ); + })} + +
+ ); +} diff --git a/web/src/components/party/results.tsx b/web/src/components/party/results.tsx new file mode 100644 index 0000000..c6d07d0 --- /dev/null +++ b/web/src/components/party/results.tsx @@ -0,0 +1,42 @@ +import { useParty } from "#/hooks/use-party"; + +export function Results() { + const { party, members } = useParty(); + if (!party?.data) return null; + + const leaderboard = members + .map((member) => ({ + member, + score: party.data.scores[member.userId] ?? 0, + })) + .sort( + (a, b) => + b.score - a.score || + Number(a.member.joinedAt) - Number(b.member.joinedAt), + ); + + return ( +
+

Leaderboard

+
+ {leaderboard.length === 0 ? ( +

No scores yet.

+ ) : ( + leaderboard.map(({ member, score }, index) => ( +
+
+

+ {index + 1}. {member.user?.name ?? "Unknown player"} +

+
+

{score} points

+
+ )) + )} +
+
+ ); +} diff --git a/web/src/components/start-party.tsx b/web/src/components/start-party.tsx index 0ca5624..6727344 100644 --- a/web/src/components/start-party.tsx +++ b/web/src/components/start-party.tsx @@ -1,3 +1,4 @@ +import { toast } from "sonner"; import { useParty } from "#/hooks/use-party"; import { client } from "#/lib/eden"; import { Button } from "./ui/button"; @@ -13,9 +14,15 @@ export function StartParty() { diff --git a/web/src/components/user-info.tsx b/web/src/components/user-info.tsx index d568e1f..fc94be9 100644 --- a/web/src/components/user-info.tsx +++ b/web/src/components/user-info.tsx @@ -1,9 +1,12 @@ import { useParty } from "#/hooks/use-party"; import { useUser } from "#/hooks/user"; +import { client } from "#/lib/eden"; import { initials } from "#/lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; +import { Button } from "./ui/button"; import { Item, + ItemActions, ItemContent, ItemDescription, ItemMedia, @@ -33,6 +36,11 @@ export function UserInfo() { : "No party yet"} + + {party && members.length > 1 && ( + + )} + ); }