format, prepare party view

This commit is contained in:
Daniel Bulant 2026-05-01 20:53:24 +02:00
parent 6c965b9065
commit d945949fed
No known key found for this signature in database
25 changed files with 583 additions and 528 deletions

View file

@ -8,6 +8,7 @@ import "./workflows/party-analysis";
import "./dbos.ts";
import { partyApp } from "./routes/party";
import { partySocketApp, pubsub } from "./routes/party-socket";
import { quizRoutes } from "./routes/quiz.ts";
import { statsApp } from "./routes/stats.ts";
const app = new Elysia()
@ -19,6 +20,7 @@ const app = new Elysia()
.use(partyApp)
.use(partyAnalysisApp)
.use(partySocketApp)
.use(quizRoutes)
.get("/", () => ({ ok: true })),
)
.listen(4000);

View file

@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { db } from "./db";
import { party, partyMember } from "./db/schema";
import type { PartySnapshot } from "./party-types";
import type { PartySnapshot, QuizState } from "./party-types";
type DbClient = typeof db;
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
@ -58,7 +58,10 @@ export async function getPartyStatus(
},
});
return {
party: partyRecord,
party: {
...partyRecord,
data: partyRecord.data as QuizState,
},
members,
};
}

View file

@ -1,7 +1,9 @@
import type { InferSelectModel } from "drizzle-orm";
import type { party, partyMember, user } from "./db/schema";
export type Party = InferSelectModel<typeof party>;
export type Party = Omit<InferSelectModel<typeof party>, "data"> & {
data: QuizState;
};
export type PartyMember = InferSelectModel<typeof partyMember>;
export type User = InferSelectModel<typeof user>;
@ -24,15 +26,20 @@ export type PartySocketOutgoing =
| { type: "ping" }
| { type: "member_payload"; payload: unknown };
export type QuizState = {
status: "idle" | "running" | "results";
workflowId: string | null;
questionIndex: number;
currentQuestion: {
export type Question = {
text: string;
options: string[];
correct: number;
} | null;
startTimestamp: number;
endTimestamp: number;
points: number;
};
export type QuizState = {
status: "running" | "results";
workflowId: string | null;
questionIndex: number;
currentQuestion: Question | null;
answers: Record<
string,
{ playerId: string; selected: number; correct: boolean }

42
api/src/party/state.ts Normal file
View file

@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import type { db as Db } from "../db";
import { party } from "../db/schema";
import type { QuizState } from "../party-types";
import { pubsub } from "../routes/party-socket";
export async function updatePartyData(
db: typeof Db,
id: string,
data: QuizState,
) {
const members = await db.query.partyMember.findMany({
where: {
partyId: id,
},
with: {
user: true,
},
});
const partyObject = await db.query.party.findFirst({
where: {
id,
},
});
if (!partyObject) throw new Error("Missing party");
pubsub.publishPartyData(id, {
type: "party_status",
party: {
...partyObject,
data,
},
members,
});
await db
.update(party)
.set({
data: data,
lastUpdated: new Date(),
})
.where(eq(party.id, id));
}

View file

@ -4,7 +4,7 @@ import { betterAuthElysia } from "../auth";
import { db } from "../db";
import { getMemberRecord, getPartyStatus } from "../party-data";
import type { QuizState } from "../party-types";
import type { PartySocketEvent, QuizState } from "../party-types";
function userTopic(userId: string) {
return `user:${userId}`;
@ -24,6 +24,9 @@ export const pubsub = {
publish(topic: string, data: string) {
this._server?.publish(topic, data);
},
publishPartyData(partyId: string, data: PartySocketEvent) {
pubsub.publish(`party:${partyId}`, JSON.stringify(data));
},
};
async function broadcastQuizState(ws: any, partyId: string) {

View file

@ -158,16 +158,7 @@ export const quizRoutes = new Elysia()
).quiz as QuizState | undefined;
return {
quiz:
quizData ??
({
status: "idle",
workflowId: null,
questionIndex: 0,
currentQuestion: null,
answers: {},
scores: {},
} satisfies QuizState),
quiz: quizData,
};
},
{ auth: true },

View file

@ -1,12 +1,11 @@
import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk";
import { eq } from "drizzle-orm";
import { db } from "../db";
import { party, partyMember } from "../db/schema";
import { partyMember } from "../db/schema";
import { updatePartyData } from "../party/state";
import type { QuizState } from "../party-types";
import { pubsub } from "../routes/party-socket";
const TOTAL_QUESTIONS = 5;
const QUESTION_TIMEOUT_SECONDS = 30;
export const quizQueue = new WorkflowQueue("quiz_queue", {
concurrency: 1,
@ -36,7 +35,6 @@ export class QuizWorkflow extends ConfiguredInstance {
// Initialize quiz state
await this.updatePartyData(partyId, quizState);
await this.broadcastState(partyId, quizState);
// Get party members to initialize scores
const members = await this.getPartyMembers(partyId);
@ -52,20 +50,14 @@ export class QuizWorkflow extends ConfiguredInstance {
quizState.answers = {};
await this.updatePartyData(partyId, quizState);
await this.broadcastState(partyId, quizState);
await DBOS.setEvent(`quiz_q${i}_question`, question);
await DBOS.setEvent(`quiz_q${i}_status`, "question");
await DBOS.setEvent(`quiz_q${i}_index`, i);
// Wait for all responses with timeout
const memberIds = new Set(members.map((m) => m.userId));
const receivedPlayers = new Set<string>();
while (receivedPlayers.size < memberIds.size) {
const response = await DBOS.recv<Response>(
"quiz_responses",
QUESTION_TIMEOUT_SECONDS,
);
const response = await DBOS.recv<Response>("quiz_responses", {
deadlineEpochMS: question.endTimestamp,
});
if (response === null) {
// Timeout - fill in missing players with no answer
@ -77,9 +69,9 @@ export class QuizWorkflow extends ConfiguredInstance {
selected: -1,
correct: false,
};
await this.updatePartyData(partyId, quizState);
}
}
await this.broadcastState(partyId, quizState);
break;
}
@ -92,23 +84,16 @@ export class QuizWorkflow extends ConfiguredInstance {
if (isCorrect) {
quizState.scores[response.playerId] =
(quizState.scores[response.playerId] ?? 0) + 10;
(quizState.scores[response.playerId] ?? 0) + question.points;
}
await this.updatePartyData(partyId, quizState);
await this.broadcastState(partyId, quizState);
await DBOS.setEvent(`quiz_q${i}_answers`, quizState.answers);
await DBOS.setEvent(`quiz_q${i}_scores`, quizState.scores);
await DBOS.setEvent(`quiz_q${i}_status`, "results");
}
}
// Quiz complete
quizState.status = "results";
await this.updatePartyData(partyId, quizState);
await this.broadcastState(partyId, quizState);
await DBOS.setEvent("quiz_final_status", "results");
await DBOS.setEvent("quiz_final_scores", quizState.scores);
}
@DBOS.step()
@ -116,25 +101,8 @@ export class QuizWorkflow extends ConfiguredInstance {
partyId: string,
quizState: QuizState,
): Promise<void> {
await db.transaction(async (tx) => {
const currentParty = await tx
.select({ data: party.data })
.from(party)
.where(eq(party.id, partyId))
.limit(1)
.then((rows) => rows[0]);
if (!currentParty) return;
const currentData = (currentParty.data ?? {}) as Record<string, unknown>;
await tx
.update(party)
.set({
data: { ...currentData, quiz: quizState },
lastUpdated: new Date(),
})
.where(eq(party.id, partyId));
});
console.log(partyId, quizState);
await updatePartyData(db, partyId, quizState);
}
@DBOS.step()
@ -142,37 +110,46 @@ export class QuizWorkflow extends ConfiguredInstance {
text: string;
options: string[];
correct: number;
startTimestamp: number;
endTimestamp: number;
points: number;
}> {
// Placeholder - returns same question for now, question generation comes later
const questions: {
text: string;
options: string[];
correct: number;
points: number;
}[] = [
{
text: "What is the most common genre in your party's shared taste?",
options: ["Hip-Hop", "Rock", "Electronic", "Jazz"],
correct: 0,
points: 10,
},
{
text: "Which artist do most party members follow?",
options: ["Artist A", "Artist B", "Artist C", "Artist D"],
correct: 1,
points: 10,
},
{
text: "What percentage of the party shares at least 1 album?",
options: ["0-25%", "25-50%", "50-75%", "75-100%"],
correct: 2,
points: 10,
},
{
text: "Who has the most diverse taste in the party?",
options: ["Player A", "Player B", "Player C", "Player D"],
correct: 0,
points: 10,
},
{
text: "Which track appears most in everyone's top 50?",
options: ["Track A", "Track B", "Track C", "Track D"],
correct: 3,
points: 10,
},
];
@ -180,18 +157,11 @@ export class QuizWorkflow extends ConfiguredInstance {
if (!question) {
throw new Error("Question not found");
}
return question;
}
@DBOS.step()
private async broadcastState(
partyId: string,
quizState: QuizState,
): Promise<void> {
pubsub.publish(
`party:${partyId}`,
JSON.stringify({ type: "quiz_state", quiz: quizState }),
);
return {
...question,
startTimestamp: Date.now(),
endTimestamp: Date.now() + 60_000,
};
}
@DBOS.step()

View file

@ -1,5 +1,3 @@
import React from "react";
const SpotifyIconIcon = ({
size = undefined,
color = "#000000",

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode";
import { useEffect, useMemo, useState } from "react";
import { Button } from "#/components/ui/button";
import {
Dialog,
@ -59,7 +59,9 @@ export function PartyQr() {
<ItemDescription>Invite someone to your party</ItemDescription>
<ItemActions>
<Dialog>
<DialogTrigger render={<Button size="sm" variant="outline" />}>Show QR</DialogTrigger>
<DialogTrigger render={<Button size="sm" variant="outline" />}>
Show QR
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Party invite</DialogTitle>
@ -88,7 +90,9 @@ export function PartyQr() {
) : null}
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Close</DialogClose>
<DialogClose render={<Button variant="outline" />}>
Close
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,6 @@
import { useParty } from "#/hooks/use-party";
export function PartyView() {
const { party } = useParty();
if (!party) return null;
}

View file

@ -1,5 +1,6 @@
import { client } from "#/lib/eden";
import { useQuery } from "@tanstack/react-query";
import { Avatar, AvatarGroup, AvatarImage } from "#/components/ui/avatar";
import { Badge } from "#/components/ui/badge";
import {
Card,
CardContent,
@ -7,7 +8,6 @@ import {
CardHeader,
CardTitle,
} from "#/components/ui/card";
import { Avatar, AvatarGroup, AvatarImage } from "#/components/ui/avatar";
import {
Item,
ItemContent,
@ -16,8 +16,8 @@ import {
ItemMedia,
ItemTitle,
} from "#/components/ui/item";
import { Badge } from "#/components/ui/badge";
import { Spinner } from "#/components/ui/spinner";
import { client } from "#/lib/eden";
import { Section, SectionTitle } from "./ui/section";
const MAX_AVATARS = 6;

View file

@ -0,0 +1,25 @@
import { useParty } from "#/hooks/use-party";
import { client } from "#/lib/eden";
import { Button } from "./ui/button";
import { Empty, EmptyContent, EmptyHeader, EmptyTitle } from "./ui/empty";
export function StartParty() {
const { party } = useParty();
if (!party) return null;
return (
<Empty>
<EmptyHeader>
<EmptyTitle>Start party</EmptyTitle>
</EmptyHeader>
<EmptyContent>
<Button
onClick={() =>
client.api.party({ partyId: party.id }).quiz.start.post()
}
>
Start
</Button>
</EmptyContent>
</Empty>
);
}

View file

@ -3,7 +3,7 @@ import { useState } from "react";
import { toast } from "sonner";
import { client } from "#/lib/eden";
import { Button } from "./ui/button";
import { Item, ItemActions, ItemDescription, ItemTitle } from "./ui/item";
import { Item, ItemActions, ItemDescription } from "./ui/item";
export function SyncButton() {
const query = useQueryClient();

View file

@ -1,4 +1,3 @@
import { useRouteContext } from "@tanstack/react-router";
import { useParty } from "#/hooks/use-party";
import { useUser } from "#/hooks/user";
import { initials } from "#/lib/utils";

View file

@ -42,6 +42,7 @@ export function usePartySocket({
ws.onmessage = (event) => {
const parsed = JSON.parse(event.data) as PartySocketEvent;
console.log(parsed);
handlerRef.current?.(parsed);
};

View file

@ -1,6 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import type {
PartyMember,
PartySocketEvent,
PartyState,
} from "../../../api/src/party-types";

View file

@ -1,6 +1,6 @@
import type { QueryClient } from "@tanstack/react-query";
import { createAuthClient } from "better-auth/react";
import type { AuthSession } from "./auth.serverfn";
import type { QueryClient } from "@tanstack/react-query";
export const authClient = createAuthClient();

View file

@ -7,22 +7,22 @@ import {
Scripts,
} from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { Toaster } from "@/components/ui/sonner";
import TanStackQueryDevtools from "../integrations/tanstack-query/devtools";
import appCss from "../styles.css?url";
import type { AuthSession } from "#/lib/auth.serverfn";
import { fetchSession, sessionQueryKey } from "#/lib/auth-client";
import type * as React from "react";
import { useEffect } from "react";
import { toast } from "sonner";
import type { AuthSession } from "#/lib/auth.serverfn";
import { fetchSession, sessionQueryKey } from "#/lib/auth-client";
import { client } from "#/lib/eden";
import {
clearPendingPartyJoin,
clearJoinIdFromLocation,
clearPendingPartyJoin,
getJoinIdFromLocation,
readPendingPartyJoin,
writePendingPartyJoin,
} from "#/lib/party-join";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import TanStackQueryDevtools from "../integrations/tanstack-query/devtools";
import appCss from "../styles.css?url";
interface MyRouterContext {
queryClient: QueryClient;
@ -39,7 +39,7 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
content: "width=device-width, initial-scale=1",
},
{
title: "TanStack Start Starter",
title: "Music Quiz",
},
],
links: [
@ -111,7 +111,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
toast.success("Joined party.");
clearPendingPartyJoin();
}
} catch (error) {
} catch (_error) {
if (!cancelled) {
toast.error("Failed to join party.");
}

View file

@ -1,19 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";
import { PartyView } from "#/components/party/party-view";
import { PartyQr } from "#/components/party-qr";
import { StartParty } from "#/components/start-party";
import { SyncButton } from "#/components/sync-button";
import { MainContent } from "#/components/ui/main-content";
import { UserInfo } from "#/components/user-info";
import { useParty } from "#/hooks/use-party";
import { useUser } from "#/hooks/user";
export const Route = createFileRoute("/")({ component: App });
function App() {
const { user } = useUser();
const { party, members } = useParty();
return (
<MainContent>
<UserInfo />
{!user?.lastSyncAt && <SyncButton />}
{user && <PartyQr />}
{party && !party.data && members.length > 1 && <StartParty />}
{party?.data && <PartyView />}
</MainContent>
);
}

View file

@ -23,8 +23,7 @@ const config = defineConfig({
"/api": {
target: "http://localhost:4000",
changeOrigin: true,
rewrite: (path) =>
path.replace(/^\/api/, "/api"),
rewrite: (path) => path.replace(/^\/api/, "/api"),
},
"/api/party-socket/ws": {
target: "ws://localhost:4000",