From 7609feead0a1a10670d203e0ba3b3b3c10ae7e3d Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Sun, 24 May 2026 18:36:58 +0200 Subject: [PATCH] keep playing --- api/src/party-types.ts | 2 +- api/src/workflows/quiz.ts | 17 +++- dev-proxy/index.ts | 4 +- web/src/components/party/party-view.tsx | 16 ++- web/src/components/party/question-review.tsx | 99 +++++++++++++++++++ web/src/components/party/question.tsx | 89 ++--------------- web/src/components/party/spotify-playback.tsx | 94 ++++++++++++++++++ web/vite.config.ts | 12 +-- 8 files changed, 238 insertions(+), 95 deletions(-) create mode 100644 web/src/components/party/question-review.tsx create mode 100644 web/src/components/party/spotify-playback.tsx 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() { - - - - {hasResponded ? "You responded" : "Waiting on your response"} - - {() => timeLeft} - - + + + {hasResponded ? "You responded" : "Waiting on your response"} + + {() => timeLeft} + {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",