wip answering
This commit is contained in:
parent
d945949fed
commit
792d46beb3
7 changed files with 218 additions and 45 deletions
|
|
@ -76,21 +76,19 @@ export const quizRoutes = new Elysia()
|
||||||
.post(
|
.post(
|
||||||
"/response",
|
"/response",
|
||||||
async ({ user, body, params, set }) => {
|
async ({ user, body, params, set }) => {
|
||||||
const existingQuiz = await db
|
const party = await db.query.party.findFirst({
|
||||||
.select({ data: party.data })
|
where: {
|
||||||
.from(party)
|
id: params.partyId,
|
||||||
.where(eq(party.id, params.partyId))
|
},
|
||||||
.limit(1)
|
});
|
||||||
.then((rows) => rows[0]);
|
|
||||||
|
|
||||||
if (!existingQuiz) {
|
if (!party) {
|
||||||
set.status = 404;
|
set.status = 404;
|
||||||
return { error: "Party not found" };
|
return { error: "Party not found" };
|
||||||
}
|
}
|
||||||
|
const quizData = party.data as QuizState | null;
|
||||||
|
|
||||||
const quizData = (
|
console.log("response quiz data", party, quizData);
|
||||||
(existingQuiz.data ?? {}) as Record<string, unknown>
|
|
||||||
).quiz as QuizState | undefined;
|
|
||||||
|
|
||||||
if (!quizData || quizData.status !== "running") {
|
if (!quizData || quizData.status !== "running") {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
|
|
@ -108,27 +106,6 @@ export const quizRoutes = new Elysia()
|
||||||
"quiz_responses",
|
"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" };
|
return { message: "Response recorded" };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize quiz state
|
// Initialize quiz state
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
|
|
||||||
// Get party members to initialize scores
|
// Get party members to initialize scores
|
||||||
const members = await this.getPartyMembers(partyId);
|
const members = await QuizWorkflow.getPartyMembers(partyId);
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
quizState.scores[member.userId] = 0;
|
quizState.scores[member.userId] = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -45,11 +45,11 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
||||||
quizState.questionIndex = i;
|
quizState.questionIndex = i;
|
||||||
|
|
||||||
const question = await this.generateQuestion(i);
|
const question = await QuizWorkflow.generateQuestion(i);
|
||||||
quizState.currentQuestion = question;
|
quizState.currentQuestion = question;
|
||||||
quizState.answers = {};
|
quizState.answers = {};
|
||||||
|
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
// Wait for all responses with timeout
|
// Wait for all responses with timeout
|
||||||
const memberIds = new Set(members.map((m) => m.userId));
|
const memberIds = new Set(members.map((m) => m.userId));
|
||||||
const receivedPlayers = new Set<string>();
|
const receivedPlayers = new Set<string>();
|
||||||
|
|
@ -69,7 +69,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
selected: -1,
|
selected: -1,
|
||||||
correct: false,
|
correct: false,
|
||||||
};
|
};
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -87,17 +87,17 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
(quizState.scores[response.playerId] ?? 0) + question.points;
|
(quizState.scores[response.playerId] ?? 0) + question.points;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quiz complete
|
// Quiz complete
|
||||||
quizState.status = "results";
|
quizState.status = "results";
|
||||||
await this.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async updatePartyData(
|
private static async updatePartyData(
|
||||||
partyId: string,
|
partyId: string,
|
||||||
quizState: QuizState,
|
quizState: QuizState,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -106,7 +106,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
async generateQuestion(index: number): Promise<{
|
static async generateQuestion(index: number): Promise<{
|
||||||
text: string;
|
text: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
correct: number;
|
correct: number;
|
||||||
|
|
@ -165,7 +165,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async getPartyMembers(
|
private static async getPartyMembers(
|
||||||
partyId: string,
|
partyId: string,
|
||||||
): Promise<{ id: string; userId: string }[]> {
|
): Promise<{ id: string; userId: string }[]> {
|
||||||
return db
|
return db
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import { useParty } from "#/hooks/use-party";
|
import { useParty } from "#/hooks/use-party";
|
||||||
|
import { Question } from "./question";
|
||||||
|
import { Results } from "./results";
|
||||||
|
|
||||||
export function PartyView() {
|
export function PartyView() {
|
||||||
const { party } = useParty();
|
const { party } = useParty();
|
||||||
if (!party) return null;
|
if (!party?.data) return null;
|
||||||
|
|
||||||
|
switch (party.data.status) {
|
||||||
|
case "running":
|
||||||
|
return <Question />;
|
||||||
|
case "results":
|
||||||
|
return <Results />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
130
web/src/components/party/question.tsx
Normal file
130
web/src/components/party/question.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
web/src/components/party/results.tsx
Normal file
42
web/src/components/party/results.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useParty } from "#/hooks/use-party";
|
import { useParty } from "#/hooks/use-party";
|
||||||
import { client } from "#/lib/eden";
|
import { client } from "#/lib/eden";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
@ -13,9 +14,15 @@ export function StartParty() {
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
<EmptyContent>
|
<EmptyContent>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
client.api.party({ partyId: party.id }).quiz.start.post()
|
try {
|
||||||
}
|
await client.api.party({ partyId: party.id }).quiz.start.post();
|
||||||
|
} catch (e) {
|
||||||
|
toast(
|
||||||
|
(e as Error)?.message || "Unknown error while starting party",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useParty } from "#/hooks/use-party";
|
import { useParty } from "#/hooks/use-party";
|
||||||
import { useUser } from "#/hooks/user";
|
import { useUser } from "#/hooks/user";
|
||||||
|
import { client } from "#/lib/eden";
|
||||||
import { initials } from "#/lib/utils";
|
import { initials } from "#/lib/utils";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
|
ItemActions,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
ItemDescription,
|
ItemDescription,
|
||||||
ItemMedia,
|
ItemMedia,
|
||||||
|
|
@ -33,6 +36,11 @@ export function UserInfo() {
|
||||||
: "No party yet"}
|
: "No party yet"}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
{party && members.length > 1 && (
|
||||||
|
<Button onClick={() => client.api.party.leave.post()}>Leave</Button>
|
||||||
|
)}
|
||||||
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue