wip answering

This commit is contained in:
Daniel Bulant 2026-05-02 01:25:19 +02:00
parent d945949fed
commit 792d46beb3
No known key found for this signature in database
7 changed files with 218 additions and 45 deletions

View file

@ -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<string, unknown>
).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<string, unknown>
).quiz as QuizState | undefined;
if (updatedQuizData) {
pubsub.publish(
`party:${params.partyId}`,
JSON.stringify({
type: "quiz_state",
quiz: updatedQuizData,
}),
);
}
return { message: "Response recorded" };
},
{

View file

@ -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<string>();
@ -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<void> {
@ -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

View file

@ -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 <Question />;
case "results":
return <Results />;
}
}

View file

@ -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<number | null>(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 (
<Section>
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{question.text}</ItemTitle>
<ItemDescription>{question.points} points</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemContent>
<ItemHeader>
<Progress value={progressValue}>
<ProgressLabel>
{hasResponded ? "You responded" : "Waiting on your response"}
</ProgressLabel>
<ProgressValue>{() => timeLeft}</ProgressValue>
</Progress>
</ItemHeader>
<ItemDescription>
{answeredCount} / {members.length} responses
</ItemDescription>
</ItemContent>
</Item>
{question.options.map((option, index) => {
const isSelected = currentSelection === index;
return (
<Item key={option} variant={isSelected ? "outline" : "default"}>
<ItemContent>
<ItemHeader>
<ItemTitle>
{index + 1}. {option}
</ItemTitle>
{isSelected && <ItemDescription>Selected</ItemDescription>}
</ItemHeader>
</ItemContent>
<Button
variant={isSelected ? "secondary" : "default"}
disabled={hasResponded || isSubmitting}
onClick={() => handleAnswer(index)}
>
{isSubmitting && isSelected ? <Spinner /> : "Answer"}
</Button>
</Item>
);
})}
</ItemGroup>
</Section>
);
}

View file

@ -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 (
<div className="space-y-4">
<h2 className="text-2xl font-semibold text-foreground">Leaderboard</h2>
<div className="space-y-2">
{leaderboard.length === 0 ? (
<p className="text-sm text-muted-foreground">No scores yet.</p>
) : (
leaderboard.map(({ member, score }, index) => (
<div
key={member.id}
className="flex items-center justify-between rounded-xl border border-foreground/10 bg-card px-4 py-3"
>
<div>
<p className="font-medium text-foreground">
{index + 1}. {member.user?.name ?? "Unknown player"}
</p>
</div>
<p className="text-sm text-muted-foreground">{score} points</p>
</div>
))
)}
</div>
</div>
);
}

View file

@ -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() {
</EmptyHeader>
<EmptyContent>
<Button
onClick={() =>
client.api.party({ partyId: party.id }).quiz.start.post()
}
onClick={async () => {
try {
await client.api.party({ partyId: party.id }).quiz.start.post();
} catch (e) {
toast(
(e as Error)?.message || "Unknown error while starting party",
);
}
}}
>
Start
</Button>

View file

@ -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"}
</ItemDescription>
</ItemContent>
<ItemActions>
{party && members.length > 1 && (
<Button onClick={() => client.api.party.leave.post()}>Leave</Button>
)}
</ItemActions>
</Item>
);
}