keep playing
This commit is contained in:
parent
96286eb424
commit
7609feead0
8 changed files with 238 additions and 95 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <Question />;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SpotifyPlayback />
|
||||
<Question />
|
||||
</div>
|
||||
);
|
||||
case "review":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SpotifyPlayback />
|
||||
<QuestionReview />
|
||||
</div>
|
||||
);
|
||||
case "results":
|
||||
return <Results />;
|
||||
}
|
||||
|
|
|
|||
99
web/src/components/party/question-review.tsx
Normal file
99
web/src/components/party/question-review.tsx
Normal file
|
|
@ -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<ReturnType<typeof useParty>["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<ReturnType<typeof useParty>["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 (
|
||||
<Section>
|
||||
<SectionTitle>
|
||||
Question {party.data.questionIndex + 1} Results
|
||||
</SectionTitle>
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{question.text}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Correct answer: {formatCorrectAnswer(question)}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
{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 (
|
||||
<Item
|
||||
key={member.id}
|
||||
variant={isCorrect ? "outline" : "default"}
|
||||
className={cn(
|
||||
isCorrect && "border-green-500/50 bg-green-500/10",
|
||||
response && !isCorrect && "border-red-500/30 bg-red-500/5",
|
||||
)}
|
||||
>
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<ItemTitle>{member.user?.name ?? "Unknown player"}</ItemTitle>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
+{response?.pointsGained ?? 0}
|
||||
</div>
|
||||
</ItemHeader>
|
||||
<ItemDescription>
|
||||
{didAnswer && response
|
||||
? formatAnswer(
|
||||
question,
|
||||
response.selectedValue ?? response.selected,
|
||||
)
|
||||
: "No answer"}
|
||||
{didAnswer ? ` - ${outcome}` : ""}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</ItemGroup>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<number | null>(null);
|
||||
const [selectedValue, setSelectedValue] = useState<number | null>(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 (
|
||||
<Section>
|
||||
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
|
||||
|
|
@ -192,62 +160,17 @@ export function Question() {
|
|||
</Item>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<Progress value={progressValue}>
|
||||
<ProgressLabel>
|
||||
{hasResponded ? "You responded" : "Waiting on your response"}
|
||||
</ProgressLabel>
|
||||
<ProgressValue>{() => timeLeft}</ProgressValue>
|
||||
</Progress>
|
||||
</ItemHeader>
|
||||
<Progress value={progressValue}>
|
||||
<ProgressLabel>
|
||||
{hasResponded ? "You responded" : "Waiting on your response"}
|
||||
</ProgressLabel>
|
||||
<ProgressValue>{() => timeLeft}</ProgressValue>
|
||||
</Progress>
|
||||
<ItemDescription>
|
||||
{answeredCount} / {members.length} responses
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<Label htmlFor="spotify-playback" className="cursor-pointer">
|
||||
Audio playback
|
||||
</Label>
|
||||
<Switch
|
||||
id="spotify-playback"
|
||||
checked={spotifyEnabled}
|
||||
disabled={spotifyIsLoading || !spotifyTrackUri}
|
||||
onCheckedChange={(checked) =>
|
||||
void setSpotifyEnabled(checked === true)
|
||||
}
|
||||
/>
|
||||
</ItemHeader>
|
||||
<ItemDescription>
|
||||
{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 ? (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSpotifyRelink()}
|
||||
disabled={isRelinkingSpotify}
|
||||
>
|
||||
{isRelinkingSpotify
|
||||
? "Opening Spotify..."
|
||||
: "Grant Spotify playback permission"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
{question.type === "numeric" ? (
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
|
|
|
|||
94
web/src/components/party/spotify-playback.tsx
Normal file
94
web/src/components/party/spotify-playback.tsx
Normal file
|
|
@ -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 (
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<Label htmlFor="spotify-playback" className="cursor-pointer">
|
||||
Audio playback
|
||||
</Label>
|
||||
<Switch
|
||||
id="spotify-playback"
|
||||
checked={spotifyEnabled}
|
||||
disabled={spotifyIsLoading || !spotifyTrackUri}
|
||||
onCheckedChange={(checked) =>
|
||||
void setSpotifyEnabled(checked === true)
|
||||
}
|
||||
/>
|
||||
</ItemHeader>
|
||||
<ItemDescription>
|
||||
{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 ? (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSpotifyRelink()}
|
||||
disabled={isRelinkingSpotify}
|
||||
>
|
||||
{isRelinkingSpotify
|
||||
? "Opening Spotify..."
|
||||
: "Grant Spotify playback permission"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue