Compare commits
No commits in common. "bfeb44a6258763fc93eaf1306fd819c7f2a621aa" and "8d2f86b3f82647a8e2a5f17542d253abf2f08208" have entirely different histories.
bfeb44a625
...
8d2f86b3f8
16 changed files with 274 additions and 836 deletions
|
|
@ -16,9 +16,6 @@ export async function getPartyForUser(userId: string) {
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
orderBy: {
|
|
||||||
joinedAt: "desc",
|
|
||||||
},
|
|
||||||
with: {
|
with: {
|
||||||
party: true,
|
party: true,
|
||||||
},
|
},
|
||||||
|
|
@ -80,68 +77,23 @@ export async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
|
||||||
await dbClient.delete(party).where(eq(party.id, partyId));
|
await dbClient.delete(party).where(eq(party.id, partyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LeavePartyResult = {
|
export async function leaveParty(dbClient: DbLike, userId: string) {
|
||||||
affectedPartyIds: string[];
|
const member = await getMemberRecord(dbClient, userId);
|
||||||
replacementPartyId: string | null;
|
if (!member) return null;
|
||||||
};
|
await dbClient.delete(partyMember).where(eq(partyMember.id, member.id));
|
||||||
|
const nextHost = await dbClient.query.partyMember.findFirst({
|
||||||
export async function leaveParty(
|
|
||||||
dbClient: DbLike,
|
|
||||||
userId: string,
|
|
||||||
options: { createReplacementParty?: boolean } = {},
|
|
||||||
): Promise<LeavePartyResult | null> {
|
|
||||||
const memberships = await dbClient.query.partyMember.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
userId,
|
partyId: member.partyId,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
joinedAt: "desc",
|
joinedAt: "asc",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (memberships.length === 0) {
|
let newHostId: string | null = null;
|
||||||
if (!options.createReplacementParty) return null;
|
if (nextHost) {
|
||||||
const created = await dbClient
|
|
||||||
.insert(party)
|
|
||||||
.values({
|
|
||||||
status: "created",
|
|
||||||
hostId: userId,
|
|
||||||
})
|
|
||||||
.returning({ id: party.id });
|
|
||||||
const replacementPartyId = created[0]?.id ?? null;
|
|
||||||
if (replacementPartyId) {
|
|
||||||
await dbClient.insert(partyMember).values({
|
|
||||||
partyId: replacementPartyId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
affectedPartyIds: [],
|
|
||||||
replacementPartyId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const affectedPartyIds = [
|
|
||||||
...new Set(memberships.map((member) => member.partyId)),
|
|
||||||
];
|
|
||||||
await dbClient.delete(partyMember).where(eq(partyMember.userId, userId));
|
|
||||||
|
|
||||||
for (const partyId of affectedPartyIds) {
|
|
||||||
const nextHost = await dbClient.query.partyMember.findFirst({
|
|
||||||
where: {
|
|
||||||
partyId,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
joinedAt: "asc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!nextHost) {
|
|
||||||
await cleanupPartyIfEmpty(dbClient, partyId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentParty = await dbClient.query.party.findFirst({
|
const currentParty = await dbClient.query.party.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: partyId,
|
id: member.partyId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (currentParty?.hostId === userId) {
|
if (currentParty?.hostId === userId) {
|
||||||
|
|
@ -151,30 +103,13 @@ export async function leaveParty(
|
||||||
hostId: nextHost.userId,
|
hostId: nextHost.userId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, partyId));
|
.where(eq(party.id, member.partyId));
|
||||||
|
newHostId = nextHost.userId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await cleanupPartyIfEmpty(dbClient, member.partyId);
|
||||||
let replacementPartyId: string | null = null;
|
|
||||||
if (options.createReplacementParty) {
|
|
||||||
const created = await dbClient
|
|
||||||
.insert(party)
|
|
||||||
.values({
|
|
||||||
status: "created",
|
|
||||||
hostId: userId,
|
|
||||||
})
|
|
||||||
.returning({ id: party.id });
|
|
||||||
replacementPartyId = created[0]?.id ?? null;
|
|
||||||
if (replacementPartyId) {
|
|
||||||
await dbClient.insert(partyMember).values({
|
|
||||||
partyId: replacementPartyId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
affectedPartyIds,
|
partyId: member.partyId,
|
||||||
replacementPartyId,
|
newHostId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ type BaseQuestion = {
|
||||||
points: number;
|
points: number;
|
||||||
song?: Song;
|
song?: Song;
|
||||||
hideSongTitle?: boolean;
|
hideSongTitle?: boolean;
|
||||||
questionKey?: string;
|
|
||||||
subjectKey?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Question =
|
export type Question =
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { db } from "../../db";
|
|
||||||
import { partyMember } from "../../db/schema";
|
|
||||||
import { getPartyForUser, leaveParty } from "../../party-data";
|
|
||||||
import { createParty, createUser, joinParty } from "../../test/factories";
|
|
||||||
|
|
||||||
describe("party data lifecycle", () => {
|
|
||||||
it("moves a leaving user to a fresh party and clears stale memberships", async () => {
|
|
||||||
const user = await createUser("Leave Tester");
|
|
||||||
const otherA = await createUser("Other A");
|
|
||||||
const otherB = await createUser("Other B");
|
|
||||||
|
|
||||||
const firstParty = await createParty(otherA.id);
|
|
||||||
const secondParty = await createParty(otherB.id);
|
|
||||||
await joinParty(firstParty.partyId, user.id);
|
|
||||||
await joinParty(secondParty.partyId, user.id);
|
|
||||||
|
|
||||||
const result = await leaveParty(db, user.id, {
|
|
||||||
createReplacementParty: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result?.affectedPartyIds).toEqual(
|
|
||||||
expect.arrayContaining([firstParty.partyId, secondParty.partyId]),
|
|
||||||
);
|
|
||||||
expect(result?.replacementPartyId).toBeTruthy();
|
|
||||||
|
|
||||||
const memberships = await db
|
|
||||||
.select({ id: partyMember.id, partyId: partyMember.partyId })
|
|
||||||
.from(partyMember)
|
|
||||||
.where(eq(partyMember.userId, user.id));
|
|
||||||
|
|
||||||
expect(memberships).toHaveLength(1);
|
|
||||||
expect(memberships[0]?.partyId).toBe(result?.replacementPartyId);
|
|
||||||
|
|
||||||
const currentParty = await getPartyForUser(user.id);
|
|
||||||
expect(currentParty?.id).toBe(result?.replacementPartyId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { Question, QuizRound } from "../../party-types";
|
|
||||||
import { buildNumericQuestion } from "../numeric-question-generator";
|
|
||||||
import {
|
|
||||||
buildMemberOptions,
|
|
||||||
type PartyAnalytics,
|
|
||||||
type PartyQuestionMember,
|
|
||||||
pickQuestionCandidate,
|
|
||||||
} from "../question-utils";
|
|
||||||
import { buildSocialQuestion } from "../social-question-generator";
|
|
||||||
|
|
||||||
type Db = typeof import("../../db").db;
|
|
||||||
|
|
||||||
function makeChoiceQuestion(
|
|
||||||
text: string,
|
|
||||||
key: string,
|
|
||||||
subjectKey: string,
|
|
||||||
): Question {
|
|
||||||
return {
|
|
||||||
type: "choice",
|
|
||||||
text,
|
|
||||||
correct: 0,
|
|
||||||
startTimestamp: 1,
|
|
||||||
endTimestamp: 2,
|
|
||||||
points: 10,
|
|
||||||
options: ["A", "B"],
|
|
||||||
questionKey: key,
|
|
||||||
subjectKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFakeDb(trackReleaseDate: Date | null) {
|
|
||||||
const trackRecord = {
|
|
||||||
id: "track-1",
|
|
||||||
name: "Shared Track",
|
|
||||||
album: {
|
|
||||||
name: "Shared Album",
|
|
||||||
release_date: trackReleaseDate,
|
|
||||||
},
|
|
||||||
artists: [{ id: "artist-1", name: "Shared Artist" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
query: {
|
|
||||||
track: {
|
|
||||||
findFirst: vi.fn(async () => trackRecord),
|
|
||||||
findMany: vi.fn(async () => []),
|
|
||||||
},
|
|
||||||
artist: {
|
|
||||||
findFirst: vi.fn(async () => ({
|
|
||||||
id: "artist-1",
|
|
||||||
name: "Shared Artist",
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: vi.fn(() => ({
|
|
||||||
from: () => ({
|
|
||||||
where: async () => [],
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
} as unknown as Db;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("question generation", () => {
|
|
||||||
it("skips repeated question keys, subjects, and text", () => {
|
|
||||||
const history: QuizRound[] = [
|
|
||||||
{
|
|
||||||
questionIndex: 0,
|
|
||||||
question: {
|
|
||||||
...makeChoiceQuestion(
|
|
||||||
"Which genre appears most in the party analytics?",
|
|
||||||
"audio:genre:pop",
|
|
||||||
"genre:pop",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
responses: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const question = pickQuestionCandidate(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
key: "audio:genre:pop",
|
|
||||||
subjectKey: "genre:pop",
|
|
||||||
question: makeChoiceQuestion(
|
|
||||||
"Which genre appears most in the party analytics?",
|
|
||||||
"audio:genre:pop",
|
|
||||||
"genre:pop",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "audio:artist:abba",
|
|
||||||
subjectKey: "artist:abba",
|
|
||||||
question: makeChoiceQuestion(
|
|
||||||
"Which artist shows up most often in the shared audio data?",
|
|
||||||
"audio:artist:abba",
|
|
||||||
"artist:abba",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
history,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(question?.text).toBe(
|
|
||||||
"Which artist shows up most often in the shared audio data?",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when member options would require fake placeholders", () => {
|
|
||||||
const members: PartyQuestionMember[] = [
|
|
||||||
{ userId: "a", name: "Sam" },
|
|
||||||
{ userId: "b", name: "Sam" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const correctMember = members[0];
|
|
||||||
expect(correctMember).toBeDefined();
|
|
||||||
if (correctMember) {
|
|
||||||
expect(buildMemberOptions(correctMember, members)).toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips numeric questions with missing release dates and zero counts", async () => {
|
|
||||||
const db = createFakeDb(null);
|
|
||||||
const analytics = {
|
|
||||||
storyClusters: [
|
|
||||||
{
|
|
||||||
tracks: [
|
|
||||||
{
|
|
||||||
name: "Shared Track",
|
|
||||||
artists: [{ name: "Shared Artist" }],
|
|
||||||
albumName: "Shared Album",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
artists: [{ name: "Shared Artist" }],
|
|
||||||
genres: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
groupSummary: {
|
|
||||||
mostSharedGenres: [],
|
|
||||||
},
|
|
||||||
} as PartyAnalytics;
|
|
||||||
const members: PartyQuestionMember[] = [
|
|
||||||
{ userId: "a", name: "A" },
|
|
||||||
{ userId: "b", name: "B" },
|
|
||||||
];
|
|
||||||
const history: QuizRound[] = [
|
|
||||||
{
|
|
||||||
questionIndex: 0,
|
|
||||||
question: {
|
|
||||||
type: "numeric",
|
|
||||||
text: "What's the release year of Shared Album?",
|
|
||||||
correct: 2010,
|
|
||||||
startTimestamp: 1,
|
|
||||||
endTimestamp: 2,
|
|
||||||
points: 10,
|
|
||||||
range: { min: 2000, max: 2010 },
|
|
||||||
questionKey: "numeric:album-year:Shared Album",
|
|
||||||
subjectKey: "album:Shared Album",
|
|
||||||
},
|
|
||||||
responses: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const question = await buildNumericQuestion({
|
|
||||||
db,
|
|
||||||
analytics,
|
|
||||||
index: 0,
|
|
||||||
members,
|
|
||||||
history,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(question).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips social fallback names for duplicate members", async () => {
|
|
||||||
const db = createFakeDb(null);
|
|
||||||
const members: PartyQuestionMember[] = [
|
|
||||||
{ userId: "a", name: "Sam" },
|
|
||||||
{ userId: "b", name: "Sam" },
|
|
||||||
];
|
|
||||||
const quizState = {
|
|
||||||
status: "running" as const,
|
|
||||||
workflowId: null,
|
|
||||||
questionIndex: 0,
|
|
||||||
currentQuestion: null,
|
|
||||||
answers: {},
|
|
||||||
scores: { a: 3, b: 1 },
|
|
||||||
history: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const question = await buildSocialQuestion(
|
|
||||||
db,
|
|
||||||
quizState,
|
|
||||||
{
|
|
||||||
groupSummary: {
|
|
||||||
mostDiverseMember: { userId: "a", genreEntropy: 1 },
|
|
||||||
mostSharedGenres: [],
|
|
||||||
mostAlignedPair: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
members,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(question).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { db as Db } from "../db";
|
import type { db as Db } from "../db";
|
||||||
import type { Question, QuizRound } from "../party-types";
|
import type { Question } from "../party-types";
|
||||||
import {
|
import {
|
||||||
buildOptionsWithCorrect,
|
buildOptionsWithCorrect,
|
||||||
buildOrderedOptions,
|
buildOrderedOptions,
|
||||||
|
|
@ -9,9 +9,7 @@ import {
|
||||||
getTopClusterTracks,
|
getTopClusterTracks,
|
||||||
isUsableText,
|
isUsableText,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
pickQuestionCandidate,
|
|
||||||
pickRandom,
|
pickRandom,
|
||||||
type QuestionCandidate,
|
|
||||||
resolveQuestionSong,
|
resolveQuestionSong,
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
|
|
@ -19,11 +17,10 @@ export async function buildAudioMetadataQuestion(
|
||||||
dbClient: typeof Db,
|
dbClient: typeof Db,
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
index: number,
|
index: number,
|
||||||
history: QuizRound[],
|
|
||||||
): Promise<Question | null> {
|
): Promise<Question | null> {
|
||||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||||
const questions: Array<
|
const questions: Array<
|
||||||
QuestionCandidate<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">>
|
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
||||||
> = [];
|
> = [];
|
||||||
const topSong = await resolveQuestionSong(dbClient, analytics);
|
const topSong = await resolveQuestionSong(dbClient, analytics);
|
||||||
const topSongName = topSong?.name;
|
const topSongName = topSong?.name;
|
||||||
|
|
@ -37,17 +34,13 @@ export async function buildAudioMetadataQuestion(
|
||||||
);
|
);
|
||||||
if (currentSongOptions) {
|
if (currentSongOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:current-song:${topSongName}`,
|
type: "choice",
|
||||||
subjectKey: `track:${topSongName}`,
|
text: "What song is currently playing?",
|
||||||
question: {
|
options: currentSongOptions,
|
||||||
type: "choice",
|
correct: 0,
|
||||||
text: "What song is currently playing?",
|
points: 10,
|
||||||
options: currentSongOptions,
|
song: topSong ?? undefined,
|
||||||
correct: 0,
|
hideSongTitle: true,
|
||||||
points: 10,
|
|
||||||
song: topSong ?? undefined,
|
|
||||||
hideSongTitle: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -57,21 +50,14 @@ export async function buildAudioMetadataQuestion(
|
||||||
4,
|
4,
|
||||||
);
|
);
|
||||||
if (genreOptions) {
|
if (genreOptions) {
|
||||||
const topGenre = genreOptions[0];
|
questions.push({
|
||||||
if (topGenre) {
|
type: "choice",
|
||||||
questions.push({
|
text: "Which genre appears most in the party analytics?",
|
||||||
key: `audio:genre:${topGenre}`,
|
options: genreOptions,
|
||||||
subjectKey: `genre:${topGenre}`,
|
correct: 0,
|
||||||
question: {
|
points: 10,
|
||||||
type: "choice",
|
song: topSong ?? undefined,
|
||||||
text: "Which genre appears most in the party analytics?",
|
});
|
||||||
options: genreOptions,
|
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
song: topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const topArtists = getTopClusterArtists(analytics);
|
const topArtists = getTopClusterArtists(analytics);
|
||||||
|
|
@ -80,16 +66,12 @@ export async function buildAudioMetadataQuestion(
|
||||||
const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
|
const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
|
||||||
if (artistOptions) {
|
if (artistOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:artist:${topArtist}`,
|
type: "choice",
|
||||||
subjectKey: `artist:${topArtist}`,
|
text: "Which artist shows up most often in the shared audio data?",
|
||||||
question: {
|
options: artistOptions,
|
||||||
type: "choice",
|
correct: 0,
|
||||||
text: "Which artist shows up most often in the shared audio data?",
|
points: 10,
|
||||||
options: artistOptions,
|
song: topSong ?? undefined,
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
song: topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,16 +83,12 @@ export async function buildAudioMetadataQuestion(
|
||||||
: null;
|
: null;
|
||||||
if (trackOptions) {
|
if (trackOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:track:${topTrackName}`,
|
type: "choice",
|
||||||
subjectKey: `track:${topTrackName}`,
|
text: "Which track looks most shared across the party?",
|
||||||
question: {
|
options: trackOptions,
|
||||||
type: "choice",
|
correct: 0,
|
||||||
text: "Which track looks most shared across the party?",
|
points: 10,
|
||||||
options: trackOptions,
|
song: topSong ?? undefined,
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
song: topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,16 +112,12 @@ export async function buildAudioMetadataQuestion(
|
||||||
);
|
);
|
||||||
if (artistOptions) {
|
if (artistOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:performer:${randomTopTrack.name}`,
|
type: "choice",
|
||||||
subjectKey: `track:${randomTopTrack.name}`,
|
text: `Who performs "${randomTopTrack.name}"?`,
|
||||||
question: {
|
options: artistOptions,
|
||||||
type: "choice",
|
correct: 0,
|
||||||
text: `Who performs "${randomTopTrack.name}"?`,
|
points: 10,
|
||||||
options: artistOptions,
|
song: randomTrackSong ?? topSong ?? undefined,
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,16 +129,13 @@ export async function buildAudioMetadataQuestion(
|
||||||
);
|
);
|
||||||
if (trackNameOptions) {
|
if (trackNameOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:title:${randomTopTrack.name}`,
|
type: "choice",
|
||||||
subjectKey: `track:${randomTopTrack.name}`,
|
text: `What is the name of this track by ${correctArtist}?`,
|
||||||
question: {
|
options: trackNameOptions,
|
||||||
type: "choice",
|
correct: 0,
|
||||||
text: `What is the name of this track by ${correctArtist}?`,
|
points: 10,
|
||||||
options: trackNameOptions,
|
song: randomTrackSong ?? topSong ?? undefined,
|
||||||
correct: 0,
|
hideSongTitle: true,
|
||||||
points: 10,
|
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,17 +147,13 @@ export async function buildAudioMetadataQuestion(
|
||||||
);
|
);
|
||||||
if (alternateSongOptions) {
|
if (alternateSongOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:current-song:${topSongName}`,
|
type: "choice",
|
||||||
subjectKey: `track:${topSongName}`,
|
text: "Which song is this audio clip from?",
|
||||||
question: {
|
options: alternateSongOptions,
|
||||||
type: "choice",
|
correct: 0,
|
||||||
text: "Which song is this audio clip from?",
|
points: 10,
|
||||||
options: alternateSongOptions,
|
song: topSong ?? undefined,
|
||||||
correct: 0,
|
hideSongTitle: true,
|
||||||
points: 10,
|
|
||||||
song: topSong ?? undefined,
|
|
||||||
hideSongTitle: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,16 +170,12 @@ export async function buildAudioMetadataQuestion(
|
||||||
);
|
);
|
||||||
if (albumOptions) {
|
if (albumOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:album:${randomTopTrack.albumName}`,
|
type: "choice",
|
||||||
subjectKey: `track:${randomTopTrack.name}`,
|
text: `"${randomTopTrack.name}" appears on which album?`,
|
||||||
question: {
|
options: albumOptions,
|
||||||
type: "choice",
|
correct: 0,
|
||||||
text: `"${randomTopTrack.name}" appears on which album?`,
|
points: 10,
|
||||||
options: albumOptions,
|
song: randomTrackSong ?? topSong ?? undefined,
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +185,7 @@ export async function buildAudioMetadataQuestion(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const question = pickQuestionCandidate(questions, history, index);
|
const question = questions[index % questions.length];
|
||||||
if (!question) return null;
|
if (!question) throw new Error("Question not found");
|
||||||
return buildQuestionWindow(question);
|
return buildQuestionWindow(question);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,13 @@ import {
|
||||||
topArtist as topArtistTable,
|
topArtist as topArtistTable,
|
||||||
topTrack as topTrackTable,
|
topTrack as topTrackTable,
|
||||||
} from "../db/schema";
|
} from "../db/schema";
|
||||||
import type { Question, QuizRound } from "../party-types";
|
import type { Question } from "../party-types";
|
||||||
import {
|
import {
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
getReleaseYearRange,
|
getReleaseYearRange,
|
||||||
isUsableText,
|
isUsableText,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
pickQuestionCandidate,
|
|
||||||
type QuestionCandidate,
|
|
||||||
resolveQuestionSong,
|
resolveQuestionSong,
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
|
|
@ -26,12 +24,12 @@ type BuildNumericQuestionInput = {
|
||||||
analytics: PartyAnalytics;
|
analytics: PartyAnalytics;
|
||||||
index: number;
|
index: number;
|
||||||
members: PartyQuestionMember[];
|
members: PartyQuestionMember[];
|
||||||
history: QuizRound[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getAlbumReleaseYear({
|
async function getAlbumReleaseYear({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
|
index,
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||||
const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name;
|
const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name;
|
||||||
const track = trackName
|
const track = trackName
|
||||||
|
|
@ -40,15 +38,16 @@ async function getAlbumReleaseYear({
|
||||||
with: { album: true },
|
with: { album: true },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
const song = await resolveQuestionSong(db, analytics, {
|
||||||
|
trackName: track?.name ?? trackName ?? undefined,
|
||||||
|
});
|
||||||
const subject = [track?.album?.name, track?.name].find((value) =>
|
const subject = [track?.album?.name, track?.name].find((value) =>
|
||||||
isUsableText(value),
|
isUsableText(value),
|
||||||
);
|
);
|
||||||
if (!subject) return null;
|
if (!subject) return null;
|
||||||
if (!track?.album?.release_date) return null;
|
const correct =
|
||||||
const song = await resolveQuestionSong(db, analytics, {
|
track?.album?.release_date?.getFullYear() ??
|
||||||
trackName: track?.name ?? trackName ?? undefined,
|
new Date().getFullYear() - 1 - index;
|
||||||
});
|
|
||||||
const correct = track.album.release_date.getFullYear();
|
|
||||||
return {
|
return {
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
text: `What's the release year of ${subject}?`,
|
text: `What's the release year of ${subject}?`,
|
||||||
|
|
@ -56,8 +55,6 @@ async function getAlbumReleaseYear({
|
||||||
range: getReleaseYearRange(correct),
|
range: getReleaseYearRange(correct),
|
||||||
points: 10,
|
points: 10,
|
||||||
song: song ?? undefined,
|
song: song ?? undefined,
|
||||||
questionKey: `numeric:album-year:${subject}`,
|
|
||||||
subjectKey: `album:${subject}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +81,6 @@ async function countTopTrackListeners({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const correct = new Set(entries.map((e) => e.userId)).size;
|
const correct = new Set(entries.map((e) => e.userId)).size;
|
||||||
if (correct <= 0) return null;
|
|
||||||
return {
|
return {
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
text: `For how many players in the party is "${trackName}" a top track?`,
|
text: `For how many players in the party is "${trackName}" a top track?`,
|
||||||
|
|
@ -92,8 +88,6 @@ async function countTopTrackListeners({
|
||||||
range: { min: 0, max: members.length },
|
range: { min: 0, max: members.length },
|
||||||
points: 10,
|
points: 10,
|
||||||
song: song ?? undefined,
|
song: song ?? undefined,
|
||||||
questionKey: `numeric:top-track-count:${trackName}`,
|
|
||||||
subjectKey: `track:${trackName}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +116,6 @@ async function countFavouriteArtistListeners({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const correct = new Set(entries.map((e) => e.userId)).size;
|
const correct = new Set(entries.map((e) => e.userId)).size;
|
||||||
if (correct <= 0) return null;
|
|
||||||
return {
|
return {
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
text: `How many players in the party have "${artistName}" as a favourite artist?`,
|
text: `How many players in the party have "${artistName}" as a favourite artist?`,
|
||||||
|
|
@ -130,44 +123,24 @@ async function countFavouriteArtistListeners({
|
||||||
range: { min: 0, max: members.length },
|
range: { min: 0, max: members.length },
|
||||||
points: 10,
|
points: 10,
|
||||||
song: song ?? undefined,
|
song: song ?? undefined,
|
||||||
questionKey: `numeric:artist-count:${artistName}`,
|
|
||||||
subjectKey: `artist:${artistName}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildNumericQuestion(
|
export async function buildNumericQuestion(
|
||||||
input: BuildNumericQuestionInput,
|
input: BuildNumericQuestionInput,
|
||||||
): Promise<Question | null> {
|
): Promise<Question | null> {
|
||||||
const questions: Array<QuestionCandidate<NumericQuestion>> = [];
|
const questions: NumericQuestion[] = [];
|
||||||
|
|
||||||
const albumYearQ = await getAlbumReleaseYear(input);
|
const albumYearQ = await getAlbumReleaseYear(input);
|
||||||
if (albumYearQ) {
|
if (albumYearQ) questions.push(albumYearQ);
|
||||||
questions.push({
|
|
||||||
key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`,
|
|
||||||
subjectKey: albumYearQ.subjectKey,
|
|
||||||
question: albumYearQ,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const topTrackQ = await countTopTrackListeners(input);
|
const topTrackQ = await countTopTrackListeners(input);
|
||||||
if (topTrackQ) {
|
if (topTrackQ) questions.push(topTrackQ);
|
||||||
questions.push({
|
|
||||||
key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`,
|
|
||||||
subjectKey: topTrackQ.subjectKey,
|
|
||||||
question: topTrackQ,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const artistQ = await countFavouriteArtistListeners(input);
|
const artistQ = await countFavouriteArtistListeners(input);
|
||||||
if (artistQ) {
|
if (artistQ) questions.push(artistQ);
|
||||||
questions.push({
|
|
||||||
key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`,
|
|
||||||
subjectKey: artistQ.subjectKey,
|
|
||||||
question: artistQ,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const question = pickQuestionCandidate(questions, input.history, input.index);
|
const question = questions[input.index % questions.length] ?? questions[0];
|
||||||
if (!question) return null;
|
if (!question) return null;
|
||||||
return buildQuestionWindow(question);
|
return buildQuestionWindow(question);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Question, QuizState } from "../party-types";
|
||||||
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
||||||
import { buildNumericQuestion } from "./numeric-question-generator";
|
import { buildNumericQuestion } from "./numeric-question-generator";
|
||||||
import type { PartyAnalytics } from "./question-utils";
|
import type { PartyAnalytics } from "./question-utils";
|
||||||
import { buildQuestionWindow, fetchPartyMembers } from "./question-utils";
|
import { fetchPartyMembers } from "./question-utils";
|
||||||
import { buildSocialQuestion } from "./social-question-generator";
|
import { buildSocialQuestion } from "./social-question-generator";
|
||||||
|
|
||||||
export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
|
export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
|
||||||
|
|
@ -24,58 +24,21 @@ export async function generatePartyQuestion({
|
||||||
index,
|
index,
|
||||||
}: GenerateQuestionInput): Promise<Question | null> {
|
}: GenerateQuestionInput): Promise<Question | null> {
|
||||||
const members = await fetchPartyMembers(dbClient, partyId);
|
const members = await fetchPartyMembers(dbClient, partyId);
|
||||||
const preferredOrder: PartyQuestionType[] = [
|
const type: PartyQuestionType =
|
||||||
"audio-metadata",
|
index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social";
|
||||||
"social",
|
|
||||||
"numeric",
|
|
||||||
];
|
|
||||||
const rotation = index % preferredOrder.length;
|
|
||||||
const typeOrder = [
|
|
||||||
...preferredOrder.slice(rotation),
|
|
||||||
...preferredOrder.slice(0, rotation),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const type of typeOrder) {
|
|
||||||
if (type === "audio-metadata") {
|
|
||||||
const q = await buildAudioMetadataQuestion(
|
|
||||||
dbClient,
|
|
||||||
analytics,
|
|
||||||
index,
|
|
||||||
quizState.history,
|
|
||||||
);
|
|
||||||
if (q) return q;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "social") {
|
|
||||||
const q = await buildSocialQuestion(
|
|
||||||
dbClient,
|
|
||||||
quizState,
|
|
||||||
analytics,
|
|
||||||
members,
|
|
||||||
index,
|
|
||||||
);
|
|
||||||
if (q) return q;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (type === "audio-metadata") {
|
||||||
|
const q = await buildAudioMetadataQuestion(dbClient, analytics, index);
|
||||||
|
if (q) return q;
|
||||||
|
}
|
||||||
|
if (type === "numeric") {
|
||||||
const q = await buildNumericQuestion({
|
const q = await buildNumericQuestion({
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
analytics,
|
analytics,
|
||||||
index,
|
index,
|
||||||
members,
|
members,
|
||||||
history: quizState.history,
|
|
||||||
});
|
});
|
||||||
if (q) return q;
|
if (q) return q;
|
||||||
}
|
}
|
||||||
|
return buildSocialQuestion(dbClient, quizState, analytics, members, index);
|
||||||
return buildQuestionWindow({
|
|
||||||
type: "numeric" as const,
|
|
||||||
text: "How many players are in this party?",
|
|
||||||
correct: members.length,
|
|
||||||
range: { min: 0, max: members.length },
|
|
||||||
points: 5,
|
|
||||||
subjectKey: "party-size",
|
|
||||||
questionKey: `fallback:party-size:${members.length}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import type { db as Db } from "../db";
|
import type { db as Db } from "../db";
|
||||||
import type { track as trackTable } from "../db/schema";
|
import type { track as trackTable } from "../db/schema";
|
||||||
import type { QuizRound } from "../party-types";
|
|
||||||
|
|
||||||
export type PartyQuestionMember = {
|
export type PartyQuestionMember = {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -10,14 +9,7 @@ export type PartyQuestionMember = {
|
||||||
|
|
||||||
export type PartyAnalytics = {
|
export type PartyAnalytics = {
|
||||||
groupSummary?: {
|
groupSummary?: {
|
||||||
totalMembers?: number;
|
|
||||||
mostSharedGenres?: { name: string }[];
|
mostSharedGenres?: { name: string }[];
|
||||||
mostDiverseMember?: { userId: string; genreEntropy: number } | null;
|
|
||||||
mostAlignedPair?: {
|
|
||||||
userIdA: string;
|
|
||||||
userIdB: string;
|
|
||||||
similarity?: number;
|
|
||||||
} | null;
|
|
||||||
};
|
};
|
||||||
storyClusters?: {
|
storyClusters?: {
|
||||||
tracks?: {
|
tracks?: {
|
||||||
|
|
@ -39,17 +31,6 @@ export type AnalyticsTrack = {
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
memberScores?: { userId: string; score: number }[];
|
memberScores?: { userId: string; score: number }[];
|
||||||
};
|
};
|
||||||
type QuestionLike = {
|
|
||||||
text: string;
|
|
||||||
questionKey?: string;
|
|
||||||
subjectKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QuestionCandidate<T extends QuestionLike = QuestionLike> = {
|
|
||||||
key: string;
|
|
||||||
subjectKey?: string;
|
|
||||||
question: T;
|
|
||||||
};
|
|
||||||
export type QuestionSong = InferSelectModel<typeof trackTable>;
|
export type QuestionSong = InferSelectModel<typeof trackTable>;
|
||||||
|
|
||||||
export const QUESTION_DURATION_MS = 60_000;
|
export const QUESTION_DURATION_MS = 60_000;
|
||||||
|
|
@ -132,46 +113,6 @@ export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickQuestionCandidate<T extends QuestionLike>(
|
|
||||||
candidates: QuestionCandidate<T>[],
|
|
||||||
history: QuizRound[],
|
|
||||||
index: number,
|
|
||||||
): T | null {
|
|
||||||
const seenKeys = new Set<string>();
|
|
||||||
const seenSubjects = new Set<string>();
|
|
||||||
const seenTexts = new Set<string>();
|
|
||||||
|
|
||||||
for (const round of history) {
|
|
||||||
const question = round.question;
|
|
||||||
seenTexts.add(normalizeQuestionKey(question.text));
|
|
||||||
if (question.questionKey)
|
|
||||||
seenKeys.add(normalizeQuestionKey(question.questionKey));
|
|
||||||
if (question.subjectKey)
|
|
||||||
seenSubjects.add(normalizeQuestionKey(question.subjectKey));
|
|
||||||
}
|
|
||||||
|
|
||||||
const fresh = candidates.filter((candidate) => {
|
|
||||||
const key = normalizeQuestionKey(candidate.key);
|
|
||||||
const subjectKey = candidate.subjectKey
|
|
||||||
? normalizeQuestionKey(candidate.subjectKey)
|
|
||||||
: null;
|
|
||||||
const text = normalizeQuestionKey(candidate.question.text);
|
|
||||||
|
|
||||||
if (seenKeys.has(key)) return false;
|
|
||||||
if (subjectKey && seenSubjects.has(subjectKey)) return false;
|
|
||||||
if (seenTexts.has(text)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fresh.length === 0) return null;
|
|
||||||
const pool = fresh;
|
|
||||||
return pool[index % pool.length]?.question ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeQuestionKey(value: string): string {
|
|
||||||
return value.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
||||||
return (analytics?.storyClusters?.[0]?.artists ?? []).map(
|
return (analytics?.storyClusters?.[0]?.artists ?? []).map(
|
||||||
(artist) => artist.name,
|
(artist) => artist.name,
|
||||||
|
|
@ -279,7 +220,7 @@ export function buildOrderedOptions(
|
||||||
desiredCount: number,
|
desiredCount: number,
|
||||||
): string[] | null {
|
): string[] | null {
|
||||||
const options = uniqueStrings(
|
const options = uniqueStrings(
|
||||||
values.filter((value): value is string => isUsableText(value)),
|
values.filter((value): value is string => Boolean(value)),
|
||||||
);
|
);
|
||||||
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -289,10 +230,9 @@ export function buildOptionsWithCorrect(
|
||||||
candidates: string[],
|
candidates: string[],
|
||||||
desiredCount: number,
|
desiredCount: number,
|
||||||
): string[] | null {
|
): string[] | null {
|
||||||
if (!isUsableText(correct)) return null;
|
|
||||||
const options = uniqueStrings([
|
const options = uniqueStrings([
|
||||||
correct,
|
correct,
|
||||||
...candidates.filter((c) => isUsableText(c) && c !== correct),
|
...candidates.filter((c) => c !== correct),
|
||||||
]);
|
]);
|
||||||
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -331,33 +271,43 @@ export function hasClearLeader(quizState: {
|
||||||
export function getMostDiverseMember(
|
export function getMostDiverseMember(
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
): PartyQuestionMember | null {
|
): PartyQuestionMember {
|
||||||
const userId = analytics?.groupSummary?.mostDiverseMember?.userId;
|
const userId = analytics?.memberProfiles?.[0]?.userId;
|
||||||
if (!userId) return null;
|
return (
|
||||||
return members.find((member) => member.userId === userId) ?? null;
|
members.find((member) => member.userId === userId) ??
|
||||||
|
members[1] ??
|
||||||
|
members[0] ?? { userId: "", name: "Player B" }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMostAlignedMember(
|
export function getMostAlignedMember(
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
): PartyQuestionMember | null {
|
): PartyQuestionMember {
|
||||||
const userId = analytics?.groupSummary?.mostAlignedPair?.userIdA;
|
const userId = analytics?.pairwise?.[0]?.userIdA;
|
||||||
if (!userId) return null;
|
return (
|
||||||
return members.find((member) => member.userId === userId) ?? null;
|
members.find((member) => member.userId === userId) ??
|
||||||
|
members[2] ??
|
||||||
|
members[0] ?? { userId: "", name: "Player C" }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMemberOptions(
|
export function buildMemberOptions(
|
||||||
correctMember: PartyQuestionMember,
|
correctMember: PartyQuestionMember,
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
): string[] | null {
|
): string[] {
|
||||||
const desiredCount = getPartySize(members.length);
|
const desiredCount = getPartySize(members.length);
|
||||||
if (!isUsableText(correctMember.name)) return null;
|
|
||||||
const options = uniqueStrings([
|
const options = uniqueStrings([
|
||||||
correctMember.name,
|
correctMember.name,
|
||||||
...members.map((member) => member.name).filter(isUsableText),
|
...members.map((member) => member.name),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (options.length < desiredCount) return null;
|
if (options.length < desiredCount) {
|
||||||
|
for (const fallback of fallbackPlayerNames(desiredCount)) {
|
||||||
|
if (options.length >= desiredCount) break;
|
||||||
|
if (!options.includes(fallback)) options.push(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ordered = [
|
const ordered = [
|
||||||
correctMember.name,
|
correctMember.name,
|
||||||
|
|
@ -378,23 +328,27 @@ function normalizeRange(
|
||||||
return { min: max, max: min };
|
return { min: max, max: min };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackPlayerNames(count: number): string[] {
|
||||||
|
return Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, index) => `Player ${String.fromCharCode(65 + index)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildMemberPairOptions(
|
export function buildMemberPairOptions(
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
correctPair: string,
|
correctPair: string,
|
||||||
): string[] | null {
|
): string[] | null {
|
||||||
if (members.length < 3) return null;
|
if (members.length < 3) return null;
|
||||||
if (!isUsableText(correctPair)) return null;
|
const pairs: string[] = [correctPair];
|
||||||
const pairs = uniqueStrings([correctPair]);
|
|
||||||
for (let i = 0; i < members.length; i++) {
|
for (let i = 0; i < members.length; i++) {
|
||||||
for (let j = i + 1; j < members.length; j++) {
|
for (let j = i + 1; j < members.length; j++) {
|
||||||
const left = members[i];
|
const left = members[i];
|
||||||
const right = members[j];
|
const right = members[j];
|
||||||
if (!left || !right) continue;
|
if (!left || !right) continue;
|
||||||
if (!isUsableText(left.name) || !isUsableText(right.name)) continue;
|
|
||||||
const pair = `${left.name} & ${right.name}`;
|
const pair = `${left.name} & ${right.name}`;
|
||||||
if (pair !== correctPair) pairs.push(pair);
|
if (pair !== correctPair) pairs.push(pair);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const deduped = uniqueStrings(pairs);
|
return pairs.length >= 2 ? pairs.slice(0, 4) : null;
|
||||||
return deduped.length >= 2 ? deduped.slice(0, 4) : null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,14 @@ import {
|
||||||
buildMemberPairOptions,
|
buildMemberPairOptions,
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
getCurrentLeader,
|
getCurrentLeader,
|
||||||
|
getMostAlignedMember,
|
||||||
getMostDiverseMember,
|
getMostDiverseMember,
|
||||||
getTopClusterTracks,
|
getTopClusterTracks,
|
||||||
getTopTrackListener,
|
getTopTrackListener,
|
||||||
hasClearLeader,
|
hasClearLeader,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
pickQuestionCandidate,
|
|
||||||
pickRandom,
|
pickRandom,
|
||||||
type QuestionCandidate,
|
|
||||||
resolveQuestionSong,
|
resolveQuestionSong,
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
|
|
@ -26,49 +25,52 @@ export async function buildSocialQuestion(
|
||||||
): Promise<Question | null> {
|
): Promise<Question | null> {
|
||||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||||
const questions: Array<
|
const questions: Array<
|
||||||
QuestionCandidate<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">>
|
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
||||||
> = [];
|
> = [];
|
||||||
const topSong = await resolveQuestionSong(dbClient, analytics);
|
const topSong = await resolveQuestionSong(dbClient, analytics);
|
||||||
|
|
||||||
const hasMultipleMembers = members.length >= 2;
|
const hasMultipleMembers = members.length >= 2;
|
||||||
if (hasMultipleMembers && hasClearLeader(quizState)) {
|
if (hasMultipleMembers && hasClearLeader(quizState)) {
|
||||||
const leader = getCurrentLeader(quizState, members);
|
const leader = getCurrentLeader(quizState, members);
|
||||||
const options = buildMemberOptions(leader, members);
|
questions.push({
|
||||||
if (options) {
|
type: "choice",
|
||||||
questions.push({
|
text: "Who is leading the quiz right now?",
|
||||||
key: "social:leader",
|
options: buildMemberOptions(leader, members),
|
||||||
subjectKey: `member:${leader.userId}`,
|
correct: 0,
|
||||||
question: {
|
points: 10,
|
||||||
type: "choice",
|
song: topSong ?? undefined,
|
||||||
text: "Who is leading the quiz right now?",
|
});
|
||||||
options,
|
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
song: topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMultipleMembers) {
|
if (hasMultipleMembers) {
|
||||||
const diverse = getMostDiverseMember(analytics, members);
|
const diverse = getMostDiverseMember(analytics, members);
|
||||||
if (diverse) {
|
questions.push({
|
||||||
const options = buildMemberOptions(diverse, members);
|
type: "choice",
|
||||||
if (options) {
|
text: "Who looks like the most diverse listener in the party?",
|
||||||
questions.push({
|
options: buildMemberOptions(diverse, members),
|
||||||
key: "social:diverse",
|
correct: 0,
|
||||||
subjectKey: `member:${diverse.userId}`,
|
points: 10,
|
||||||
question: {
|
song: topSong ?? undefined,
|
||||||
type: "choice",
|
});
|
||||||
text: "Who looks like the most diverse listener in the party?",
|
|
||||||
options,
|
const aligned = getMostAlignedMember(analytics, members);
|
||||||
correct: 0,
|
questions.push({
|
||||||
points: 10,
|
type: "choice",
|
||||||
song: topSong ?? undefined,
|
text: "Which member seems most aligned with the rest of the party?",
|
||||||
},
|
options: buildMemberOptions(aligned, members),
|
||||||
});
|
correct: 0,
|
||||||
}
|
points: 10,
|
||||||
}
|
song: topSong ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
questions.push({
|
||||||
|
type: "choice",
|
||||||
|
text: "Who would you ask for a recommendation based on the party taste?",
|
||||||
|
options: buildMemberOptions(aligned, members),
|
||||||
|
correct: 0,
|
||||||
|
points: 10,
|
||||||
|
song: topSong ?? undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const topTracks = getTopClusterTracks(analytics);
|
const topTracks = getTopClusterTracks(analytics);
|
||||||
|
|
@ -81,43 +83,32 @@ export async function buildSocialQuestion(
|
||||||
artistNames: randomTrack.artists?.map((artist) => artist.name),
|
artistNames: randomTrack.artists?.map((artist) => artist.name),
|
||||||
albumName: randomTrack.albumName,
|
albumName: randomTrack.albumName,
|
||||||
});
|
});
|
||||||
const options = buildMemberOptions(topListener, members);
|
questions.push({
|
||||||
if (options) {
|
type: "choice",
|
||||||
questions.push({
|
text: `Who is most likely to have "${randomTrack.name}" in heavy rotation?`,
|
||||||
key: `social:track-listener:${randomTrack.name}`,
|
options: buildMemberOptions(topListener, members),
|
||||||
subjectKey: `track:${randomTrack.name}`,
|
correct: 0,
|
||||||
question: {
|
points: 10,
|
||||||
type: "choice",
|
song: randomTrackSong ?? topSong ?? undefined,
|
||||||
text: `Who listens the most to "${randomTrack.name}"?`,
|
});
|
||||||
options,
|
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (members.length >= 3 && analytics?.groupSummary?.mostAlignedPair) {
|
if (members.length >= 3 && analytics?.pairwise?.length) {
|
||||||
const topPair = analytics.groupSummary.mostAlignedPair;
|
const topPair = analytics.pairwise[0];
|
||||||
const memberA = members.find((m) => m.userId === topPair.userIdA);
|
const memberA = members.find((m) => m.userId === topPair?.userIdA);
|
||||||
const memberB = members.find((m) => m.userId === topPair.userIdB);
|
const memberB = members.find((m) => m.userId === topPair?.userIdB);
|
||||||
if (memberA && memberB) {
|
if (memberA && memberB) {
|
||||||
const correctPair = `${memberA.name} & ${memberB.name}`;
|
const correctPair = `${memberA.name} & ${memberB.name}`;
|
||||||
const pairOptions = buildMemberPairOptions(members, correctPair);
|
const pairOptions = buildMemberPairOptions(members, correctPair);
|
||||||
if (pairOptions) {
|
if (pairOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `social:pair:${memberA.userId}:${memberB.userId}`,
|
type: "choice",
|
||||||
subjectKey: `pair:${[memberA.userId, memberB.userId].sort().join("|")}`,
|
text: "Which two players would probably agree on the aux?",
|
||||||
question: {
|
options: pairOptions,
|
||||||
type: "choice",
|
correct: 0,
|
||||||
text: "Which two players share the most musical taste?",
|
points: 10,
|
||||||
options: pairOptions,
|
song: topSong ?? undefined,
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
song: topSong ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +118,7 @@ export async function buildSocialQuestion(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const question = pickQuestionCandidate(questions, quizState.history, index);
|
const question = questions[index % questions.length];
|
||||||
if (!question) return null;
|
if (!question) throw new Error("Question not found");
|
||||||
return buildQuestionWindow(question);
|
return buildQuestionWindow(question);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,17 +44,6 @@ export async function updatePartyData(
|
||||||
};
|
};
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
if (!member.userId) continue;
|
if (!member.userId) continue;
|
||||||
pubsub.publish(
|
|
||||||
`user:${member.userId}`,
|
|
||||||
JSON.stringify({
|
|
||||||
type: "party_status",
|
|
||||||
party: {
|
|
||||||
...partyObject,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
members,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
void publishDeviceEventForUser(member.userId, event);
|
void publishDeviceEventForUser(member.userId, event);
|
||||||
}
|
}
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
|
|
@ -31,17 +31,6 @@ function broadcastToUser(userId: string, event: Record<string, unknown>) {
|
||||||
void publishDeviceEventForUser(userId, event as PartySocketEvent);
|
void publishDeviceEventForUser(userId, event as PartySocketEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastStatusToMembers(snapshot: PartySnapshot | null) {
|
|
||||||
if (!snapshot) return;
|
|
||||||
for (const member of snapshot.members) {
|
|
||||||
broadcastToUser(member.userId, {
|
|
||||||
type: "party_status",
|
|
||||||
party: snapshot.party,
|
|
||||||
members: snapshot.members,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidStatus(
|
function isValidStatus(
|
||||||
status: string,
|
status: string,
|
||||||
): status is import("../party-types").PartyStatus {
|
): status is import("../party-types").PartyStatus {
|
||||||
|
|
@ -76,80 +65,88 @@ export const partyApp = new Elysia()
|
||||||
return { error: "Target user not found." };
|
return { error: "Target user not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { partyId, leaveResult } = await db.transaction(async (tx) => {
|
const { partyId, hostChanged, leaveResult } = await db.transaction(
|
||||||
const leaveResult = await leaveParty(tx, user.id, {
|
async (tx) => {
|
||||||
createReplacementParty: false,
|
const leaveResult = await leaveParty(tx, user.id);
|
||||||
});
|
let partyId: string | null = null;
|
||||||
let partyId: string | null = null;
|
let hostChanged = false;
|
||||||
|
|
||||||
const targetMembership = await getMemberRecord(tx, targetUserId);
|
const targetMembership = await getMemberRecord(tx, targetUserId);
|
||||||
if (targetMembership) {
|
if (targetMembership) {
|
||||||
partyId = targetMembership.partyId;
|
partyId = targetMembership.partyId;
|
||||||
await tx
|
await tx
|
||||||
.update(party)
|
.update(party)
|
||||||
.set({
|
.set({
|
||||||
hostId: targetUserId,
|
hostId: targetUserId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, partyId));
|
.where(eq(party.id, partyId));
|
||||||
} else {
|
hostChanged = true;
|
||||||
const created = await tx
|
} else {
|
||||||
.insert(party)
|
const created = await tx
|
||||||
.values({
|
.insert(party)
|
||||||
status: "created",
|
.values({
|
||||||
hostId: targetUserId,
|
status: "created",
|
||||||
})
|
hostId: targetUserId,
|
||||||
.returning({ id: party.id });
|
})
|
||||||
const createdId = created[0]?.id ?? null;
|
.returning({ id: party.id });
|
||||||
if (!createdId) {
|
const createdId = created[0]?.id ?? null;
|
||||||
|
if (!createdId) {
|
||||||
|
return {
|
||||||
|
partyId: null,
|
||||||
|
hostChanged,
|
||||||
|
leaveResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
partyId = createdId;
|
||||||
|
await tx.insert(partyMember).values({
|
||||||
|
partyId,
|
||||||
|
userId: targetUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partyId) {
|
||||||
return {
|
return {
|
||||||
partyId: null,
|
partyId: null,
|
||||||
|
hostChanged,
|
||||||
leaveResult,
|
leaveResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
partyId = createdId;
|
|
||||||
await tx.insert(partyMember).values({
|
|
||||||
partyId,
|
|
||||||
userId: targetUserId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!partyId) {
|
await tx
|
||||||
|
.insert(partyMember)
|
||||||
|
.values({ partyId, userId: user.id })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
partyId: null,
|
partyId,
|
||||||
|
hostChanged,
|
||||||
leaveResult,
|
leaveResult,
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
);
|
||||||
await tx
|
|
||||||
.insert(partyMember)
|
|
||||||
.values({ partyId, userId: user.id })
|
|
||||||
.onConflictDoNothing();
|
|
||||||
|
|
||||||
return {
|
|
||||||
partyId,
|
|
||||||
leaveResult,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!partyId) return { party: null, members: [] };
|
if (!partyId) return { party: null, members: [] };
|
||||||
const leaveStatuses = await Promise.all(
|
|
||||||
(leaveResult?.affectedPartyIds ?? []).map(
|
|
||||||
async (affectedPartyId) => ({
|
|
||||||
partyId: affectedPartyId,
|
|
||||||
status: await getPartyStatus(affectedPartyId),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (const { partyId: affectedPartyId, status } of leaveStatuses) {
|
|
||||||
if (status) {
|
|
||||||
broadcastSnapshot(affectedPartyId, status);
|
|
||||||
broadcastStatusToMembers(status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const status = await getPartyStatus(partyId);
|
const status = await getPartyStatus(partyId);
|
||||||
|
if (leaveResult?.newHostId) {
|
||||||
|
broadcastSnapshot(leaveResult.partyId, status);
|
||||||
|
}
|
||||||
|
if (hostChanged) {
|
||||||
|
broadcastSnapshot(partyId, status);
|
||||||
|
}
|
||||||
broadcastSnapshot(partyId, status);
|
broadcastSnapshot(partyId, status);
|
||||||
broadcastStatusToMembers(status);
|
if (status) {
|
||||||
|
broadcastToUser(targetUserId, {
|
||||||
|
type: "party_status",
|
||||||
|
party: status.party,
|
||||||
|
members: status.members,
|
||||||
|
});
|
||||||
|
broadcastToUser(user.id, {
|
||||||
|
type: "party_status",
|
||||||
|
party: status.party,
|
||||||
|
members: status.members,
|
||||||
|
});
|
||||||
|
}
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -163,30 +160,11 @@ export const partyApp = new Elysia()
|
||||||
"/leave",
|
"/leave",
|
||||||
async ({ user }) => {
|
async ({ user }) => {
|
||||||
const result = await db.transaction(async (tx) => {
|
const result = await db.transaction(async (tx) => {
|
||||||
return await leaveParty(tx, user.id, {
|
return await leaveParty(tx, user.id);
|
||||||
createReplacementParty: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
if (!result) return { party: null, members: [] };
|
if (!result) return { party: null, members: [] };
|
||||||
const leaveStatuses = await Promise.all(
|
const status = await getPartyStatus(result.partyId);
|
||||||
result.affectedPartyIds.map(async (affectedPartyId) => ({
|
broadcastSnapshot(result.partyId, status);
|
||||||
partyId: affectedPartyId,
|
|
||||||
status: await getPartyStatus(affectedPartyId),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
for (const { partyId: affectedPartyId, status } of leaveStatuses) {
|
|
||||||
if (status) {
|
|
||||||
broadcastSnapshot(affectedPartyId, status);
|
|
||||||
broadcastStatusToMembers(status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const status = result.replacementPartyId
|
|
||||||
? await getPartyStatus(result.replacementPartyId)
|
|
||||||
: null;
|
|
||||||
if (result.replacementPartyId) {
|
|
||||||
broadcastSnapshot(result.replacementPartyId, status);
|
|
||||||
}
|
|
||||||
broadcastStatusToMembers(status);
|
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,6 @@ import type { QuizState } from "../party-types";
|
||||||
import { QuizWorkflow, quizQueue } from "../workflows/quiz";
|
import { QuizWorkflow, quizQueue } from "../workflows/quiz";
|
||||||
import { pubsub } from "./party-socket";
|
import { pubsub } from "./party-socket";
|
||||||
|
|
||||||
function broadcastStatusToMembers(
|
|
||||||
status: Awaited<ReturnType<typeof getPartyStatus>>,
|
|
||||||
) {
|
|
||||||
if (!status) return;
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
type: "party_status",
|
|
||||||
party: status.party,
|
|
||||||
members: status.members,
|
|
||||||
});
|
|
||||||
pubsub.publish(`party:${status.party.id}`, payload);
|
|
||||||
for (const member of status.members) {
|
|
||||||
pubsub.publish(`user:${member.userId}`, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const quizWf = new QuizWorkflow();
|
const quizWf = new QuizWorkflow();
|
||||||
|
|
||||||
export const quizRoutes = new Elysia()
|
export const quizRoutes = new Elysia()
|
||||||
|
|
@ -60,13 +45,21 @@ export const quizRoutes = new Elysia()
|
||||||
.update(party)
|
.update(party)
|
||||||
.set({
|
.set({
|
||||||
status: "started",
|
status: "started",
|
||||||
data: null,
|
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, params.partyId));
|
.where(eq(party.id, params.partyId));
|
||||||
|
|
||||||
const status = await getPartyStatus(params.partyId);
|
const status = await getPartyStatus(params.partyId);
|
||||||
broadcastStatusToMembers(status);
|
if (status) {
|
||||||
|
pubsub.publish(
|
||||||
|
`party:${params.partyId}`,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "party_status",
|
||||||
|
party: status.party,
|
||||||
|
members: status.members,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: "Quiz started",
|
message: "Quiz started",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
|
||||||
"dev": "bun run --watch index.ts"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,6 @@ import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
|
||||||
import { useUser } from "#/hooks/user";
|
import { useUser } from "#/hooks/user";
|
||||||
import { client } from "#/lib/eden";
|
import { client } from "#/lib/eden";
|
||||||
|
|
||||||
type PartyQuestion = NonNullable<
|
|
||||||
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["currentQuestion"]
|
|
||||||
>;
|
|
||||||
|
|
||||||
function formatTimeLeft(milliseconds: number) {
|
function formatTimeLeft(milliseconds: number) {
|
||||||
const clamped = Math.max(0, milliseconds);
|
const clamped = Math.max(0, milliseconds);
|
||||||
const totalSeconds = Math.ceil(clamped / 1000);
|
const totalSeconds = Math.ceil(clamped / 1000);
|
||||||
|
|
@ -38,17 +34,6 @@ function formatTimeLeft(milliseconds: number) {
|
||||||
return `${minutes}:${seconds}`;
|
return `${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQuestionAnnouncement(question: PartyQuestion) {
|
|
||||||
if (question.type === "numeric") {
|
|
||||||
return `${question.text}. Choose a number from ${question.range.min} to ${question.range.max}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = question.options
|
|
||||||
.map((option, index) => `Option ${index + 1}: ${option}`)
|
|
||||||
.join(". ");
|
|
||||||
return `${question.text}. ${options}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Question() {
|
export function Question() {
|
||||||
const { party, members } = useParty();
|
const { party, members } = useParty();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
@ -63,10 +48,6 @@ export function Question() {
|
||||||
: null;
|
: null;
|
||||||
const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } =
|
const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } =
|
||||||
useSpotifyPlayer(spotifyTrackUri);
|
useSpotifyPlayer(spotifyTrackUri);
|
||||||
const questionStartTimestamp = question?.startTimestamp ?? null;
|
|
||||||
const questionAnnouncement = question
|
|
||||||
? getQuestionAnnouncement(question)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
|
|
@ -82,26 +63,6 @@ export function Question() {
|
||||||
setSelectedValue(question.type === "numeric" ? question.range.min : null);
|
setSelectedValue(question.type === "numeric" ? question.range.min : null);
|
||||||
}, [question]);
|
}, [question]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!questionAnnouncement ||
|
|
||||||
questionStartTimestamp == null ||
|
|
||||||
!("speechSynthesis" in window)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(questionAnnouncement);
|
|
||||||
utterance.rate = 0.95;
|
|
||||||
utterance.pitch = 1;
|
|
||||||
|
|
||||||
window.speechSynthesis.cancel();
|
|
||||||
window.speechSynthesis.speak(utterance);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.speechSynthesis.cancel();
|
|
||||||
};
|
|
||||||
}, [questionAnnouncement, questionStartTimestamp]);
|
|
||||||
if (!question)
|
if (!question)
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,8 @@ import {
|
||||||
|
|
||||||
export function UserInfo() {
|
export function UserInfo() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const {
|
const { party, members, isConnecting, isReconnecting, resetParty } =
|
||||||
party,
|
useParty();
|
||||||
members,
|
|
||||||
isConnecting,
|
|
||||||
isReconnecting,
|
|
||||||
resetParty,
|
|
||||||
setPartyState,
|
|
||||||
} = useParty();
|
|
||||||
return (
|
return (
|
||||||
<Item>
|
<Item>
|
||||||
<ItemMedia>
|
<ItemMedia>
|
||||||
|
|
@ -47,12 +41,8 @@ export function UserInfo() {
|
||||||
{party && (
|
{party && (
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const result = await client.api.party.leave.post();
|
await client.api.party.leave.post();
|
||||||
if (result?.party) {
|
resetParty();
|
||||||
setPartyState(result);
|
|
||||||
} else {
|
|
||||||
resetParty();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Leave
|
Leave
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { treaty } from "@elysiajs/eden";
|
import { treaty } from "@elysiajs/eden";
|
||||||
import type { App } from "../../../api/src/index";
|
import type { App } from "../../../api/src/index";
|
||||||
|
|
||||||
export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
|
// export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
|
||||||
// export const client = treaty<App>(
|
export const client = treaty<App>(
|
||||||
// process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
|
process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
|
||||||
// {},
|
{},
|
||||||
// );
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue