diff --git a/api/src/party-types.ts b/api/src/party-types.ts
index 427092e..8648d56 100644
--- a/api/src/party-types.ts
+++ b/api/src/party-types.ts
@@ -69,7 +69,7 @@ export type QuizRound = {
};
export type QuizState = {
- status: "running" | "results";
+ status: "running" | "review" | "results";
workflowId: string | null;
questionIndex: number;
currentQuestion: Question | null;
diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts
index ae03252..489d16e 100644
--- a/api/src/workflows/quiz.ts
+++ b/api/src/workflows/quiz.ts
@@ -14,6 +14,7 @@ import type {
import { partyAnalysisWorkflow } from "./party-analysis";
const TOTAL_QUESTIONS = 5;
+const REVIEW_DURATION_MS = 5000;
export const quizQueue = new WorkflowQueue("quiz_queue", {
concurrency: 1,
@@ -62,6 +63,7 @@ export class QuizWorkflow extends ConfiguredInstance {
}
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
+ quizState.status = "running";
quizState.questionIndex = i;
const question = await QuizWorkflow.generateQuestion(
@@ -135,9 +137,13 @@ export class QuizWorkflow extends ConfiguredInstance {
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained;
+ const response = quizState.answers[playerId];
+ if (response) response.pointsGained = gained;
}
+ quizState.status = "review";
await QuizWorkflow.updatePartyData(partyId, quizState);
+ await DBOS.sleep(REVIEW_DURATION_MS);
}
// Quiz complete
@@ -183,7 +189,11 @@ export class QuizWorkflow extends ConfiguredInstance {
]);
}
+ const noAnswers = round.responses
+ .filter((response) => response.selected < 0)
+ .map((response): [string, number] => [response.playerId, 0]);
const ordered = round.responses
+ .filter((response) => response.selected >= 0)
.map((response) => ({
response,
distance: Math.abs(
@@ -192,6 +202,7 @@ export class QuizWorkflow extends ConfiguredInstance {
),
}))
.sort((a, b) => a.distance - b.distance);
+ if (ordered.length === 0) return noAnswers;
const groups: Array<{ distance: number; responses: QuizResponse[] }> = [];
for (const item of ordered) {
@@ -204,13 +215,13 @@ export class QuizWorkflow extends ConfiguredInstance {
}
if (groups.length <= 1) {
- return round.responses.map((response) => [
+ return ordered.map(({ response }) => [
response.playerId,
round.question.points,
]);
}
- return groups.flatMap((group, index) => {
+ const scoredAnswers = groups.flatMap((group, index) => {
const factor = (groups.length - index - 1) / (groups.length - 1);
const gained = Math.round(round.question.points * factor);
return group.responses.map((response): [string, number] => [
@@ -218,6 +229,8 @@ export class QuizWorkflow extends ConfiguredInstance {
gained,
]);
});
+
+ return [...scoredAnswers, ...noAnswers];
}
@DBOS.step()
diff --git a/dev-proxy/index.ts b/dev-proxy/index.ts
index cad13ec..9f37cfa 100644
--- a/dev-proxy/index.ts
+++ b/dev-proxy/index.ts
@@ -44,7 +44,7 @@ type QuizQuestion =
};
type QuizState = {
- status: "running" | "results";
+ status: "running" | "review" | "results";
questionIndex: number;
currentQuestion: QuizQuestion | null;
};
@@ -171,7 +171,7 @@ function connectApiSocket() {
function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null {
if (!quizData.currentQuestion) return null;
- if (quizData.status === "results") return null;
+ if (quizData.status !== "running") return null;
const question = quizData.currentQuestion;
const q_type =
question.type === "choice"
diff --git a/web/src/components/party/party-view.tsx b/web/src/components/party/party-view.tsx
index cb622b7..416ccc5 100644
--- a/web/src/components/party/party-view.tsx
+++ b/web/src/components/party/party-view.tsx
@@ -1,6 +1,8 @@
import { useParty } from "#/hooks/use-party";
import { Question } from "./question";
+import { QuestionReview } from "./question-review";
import { Results } from "./results";
+import { SpotifyPlayback } from "./spotify-playback";
export function PartyView() {
const { party } = useParty();
@@ -8,7 +10,19 @@ export function PartyView() {
switch (party.data.status) {
case "running":
- return ;
+ return (
+
+
+
+
+ );
+ case "review":
+ return (
+
+
+
+
+ );
case "results":
return ;
}
diff --git a/web/src/components/party/question-review.tsx b/web/src/components/party/question-review.tsx
new file mode 100644
index 0000000..65c709e
--- /dev/null
+++ b/web/src/components/party/question-review.tsx
@@ -0,0 +1,99 @@
+import {
+ Item,
+ ItemContent,
+ ItemDescription,
+ ItemGroup,
+ ItemHeader,
+ ItemTitle,
+} from "#/components/ui/item";
+import { Section, SectionTitle } from "#/components/ui/section";
+import { useParty } from "#/hooks/use-party";
+import { cn } from "#/lib/utils";
+
+type PartyQuestion = NonNullable<
+ NonNullable["party"]>["data"]["currentQuestion"]
+>;
+
+function formatAnswer(question: PartyQuestion, selected: number) {
+ if (selected < 0) return "No answer";
+ if (question.type === "numeric") return selected.toString();
+ return question.options[selected] ?? `Option ${selected + 1}`;
+}
+
+function formatCorrectAnswer(question: PartyQuestion) {
+ if (question.type === "numeric") return question.correct.toString();
+ return question.options[question.correct] ?? `Option ${question.correct + 1}`;
+}
+
+function formatOutcome(
+ question: PartyQuestion,
+ response: NonNullable<
+ NonNullable["party"]>["data"]["answers"][string]
+ > | null,
+) {
+ if (!response || response.selected < 0) return "No answer";
+ if (response.correct)
+ return question.type === "numeric" ? "Exact" : "Correct";
+ if (question.type === "numeric" && response.pointsGained > 0)
+ return "Closest";
+ return "Incorrect";
+}
+
+export function QuestionReview() {
+ const { party, members } = useParty();
+ const question = party?.data?.currentQuestion;
+ if (!party?.data || !question) return null;
+
+ return (
+
+
+ Question {party.data.questionIndex + 1} Results
+
+
+ -
+
+ {question.text}
+
+ Correct answer: {formatCorrectAnswer(question)}
+
+
+
+ {members.map((member) => {
+ const response = party.data.answers[member.userId];
+ const didAnswer = response != null && response.selected >= 0;
+ const isCorrect = response?.correct === true;
+ const outcome = formatOutcome(question, response ?? null);
+
+ return (
+ -
+
+
+ {member.user?.name ?? "Unknown player"}
+
+ +{response?.pointsGained ?? 0}
+
+
+
+ {didAnswer && response
+ ? formatAnswer(
+ question,
+ response.selectedValue ?? response.selected,
+ )
+ : "No answer"}
+ {didAnswer ? ` - ${outcome}` : ""}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/party/question.tsx b/web/src/components/party/question.tsx
index 5044773..551fcdb 100644
--- a/web/src/components/party/question.tsx
+++ b/web/src/components/party/question.tsx
@@ -9,7 +9,6 @@ import {
ItemHeader,
ItemTitle,
} from "#/components/ui/item";
-import { Label } from "#/components/ui/label";
import {
Progress,
ProgressLabel,
@@ -18,14 +17,8 @@ import {
import { Section, SectionTitle } from "#/components/ui/section";
import { Slider } from "#/components/ui/slider";
import { Spinner } from "#/components/ui/spinner";
-import { Switch } from "#/components/ui/switch";
import { useParty } from "#/hooks/use-party";
-import {
- SPOTIFY_PLAYBACK_SCOPES,
- useSpotifyPlayer,
-} from "#/hooks/use-spotify-player";
import { useUser } from "#/hooks/user";
-import { authClient } from "#/lib/auth-client";
import { client } from "#/lib/eden";
type PartyQuestion = NonNullable<
@@ -60,20 +53,7 @@ export function Question() {
const [selected, setSelected] = useState(null);
const [selectedValue, setSelectedValue] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
- const [isRelinkingSpotify, setIsRelinkingSpotify] = useState(false);
const question = party?.data?.currentQuestion;
- const spotifyTrackUri =
- question?.song?.platform === "spotify" && question.song.platform_id
- ? `spotify:track:${question.song.platform_id}`
- : null;
- const {
- enabled: spotifyEnabled,
- setEnabled: setSpotifyEnabled,
- status: spotifyStatus,
- error: spotifyError,
- requiresRelink: spotifyRequiresRelink,
- isLoading: spotifyIsLoading,
- } = useSpotifyPlayer(spotifyTrackUri);
const questionStartTimestamp = question?.startTimestamp ?? null;
const questionAnnouncement = question
? getQuestionAnnouncement(question)
@@ -168,18 +148,6 @@ export function Question() {
}
}
- async function handleSpotifyRelink() {
- setIsRelinkingSpotify(true);
- try {
- await authClient.linkSocial({
- provider: "spotify",
- // scopes: [...SPOTIFY_PLAYBACK_SCOPES],
- });
- } finally {
- setIsRelinkingSpotify(false);
- }
- }
-
return (
Question {party.data.questionIndex + 1}
@@ -192,62 +160,17 @@ export function Question() {
-
-
-
-
+
{answeredCount} / {members.length} responses
- -
-
-
-
-
- void setSpotifyEnabled(checked === true)
- }
- />
-
-
- {question.hideSongTitle
- ? "Listen closely and guess the song."
- : (question.song?.name ??
- "This question has no associated song.")}
- {!spotifyTrackUri
- ? " Spotify playback is unavailable for this question."
- : spotifyError
- ? ` ${spotifyError}`
- : spotifyStatus === "loading"
- ? " Connecting Spotify..."
- : null}
- {spotifyRequiresRelink ? (
-
-
-
- ) : null}
-
-
-
{question.type === "numeric" ? (
-
diff --git a/web/src/components/party/spotify-playback.tsx b/web/src/components/party/spotify-playback.tsx
new file mode 100644
index 0000000..d3c9557
--- /dev/null
+++ b/web/src/components/party/spotify-playback.tsx
@@ -0,0 +1,94 @@
+import { useState } from "react";
+
+import { Button } from "#/components/ui/button";
+import {
+ Item,
+ ItemContent,
+ ItemDescription,
+ ItemGroup,
+ ItemHeader,
+} from "#/components/ui/item";
+import { Label } from "#/components/ui/label";
+import { Switch } from "#/components/ui/switch";
+import { useParty } from "#/hooks/use-party";
+import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
+import { authClient } from "#/lib/auth-client";
+
+export function SpotifyPlayback() {
+ const { party } = useParty();
+ const [isRelinkingSpotify, setIsRelinkingSpotify] = useState(false);
+ const question = party?.data?.currentQuestion;
+ const spotifyTrackUri =
+ question?.song?.platform === "spotify" && question.song.platform_id
+ ? `spotify:track:${question.song.platform_id}`
+ : null;
+ const {
+ enabled: spotifyEnabled,
+ setEnabled: setSpotifyEnabled,
+ status: spotifyStatus,
+ error: spotifyError,
+ requiresRelink: spotifyRequiresRelink,
+ isLoading: spotifyIsLoading,
+ } = useSpotifyPlayer(spotifyTrackUri);
+
+ if (!question) return null;
+
+ async function handleSpotifyRelink() {
+ setIsRelinkingSpotify(true);
+ try {
+ await authClient.linkSocial({
+ provider: "spotify",
+ });
+ } finally {
+ setIsRelinkingSpotify(false);
+ }
+ }
+
+ return (
+
+
-
+
+
+
+
+ void setSpotifyEnabled(checked === true)
+ }
+ />
+
+
+ {question.hideSongTitle
+ ? "Listen closely and guess the song."
+ : (question.song?.name ??
+ "This question has no associated song.")}
+ {!spotifyTrackUri
+ ? " Spotify playback is unavailable for this question."
+ : spotifyError
+ ? ` ${spotifyError}`
+ : spotifyStatus === "loading"
+ ? " Connecting Spotify..."
+ : null}
+ {spotifyRequiresRelink ? (
+
+
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 7d881ba..139961d 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -11,10 +11,10 @@ const config = defineConfig({
plugins: [
devtools(),
tailwindcss(),
- tanstackStart({
- spa: {
- enabled: true
- }
+ tanstackStart({
+ spa: {
+ enabled: true,
+ },
}),
viteReact({
babel: {
@@ -22,8 +22,8 @@ const config = defineConfig({
},
}),
],
- server: {
- allowedHosts: ["aura.rpi1.danbulant.cloud"],
+ server: {
+ allowedHosts: ["aura.rpi1.danbulant.cloud"],
proxy: {
"/api": {
target: "http://localhost:4000",