more questions

This commit is contained in:
Daniel Bulant 2026-05-25 18:57:07 +02:00
parent 2154c0b6d1
commit 3b7d669a5d
No known key found for this signature in database
5 changed files with 437 additions and 3 deletions

View file

@ -64,6 +64,61 @@ function createFakeDb(trackReleaseDate: Date | null) {
} as unknown as Db;
}
function createDetailedTrackDb() {
const tracks = [
{
id: "track-1",
albumId: "album-1",
platform: "spotify",
platform_id: "spotify:track:one",
name: "First Track",
popularity: 1,
duration: 180000,
explicit: false,
disc_number: 1,
track_number: 1,
album: { name: "First Album", release_date: new Date("2001-01-01") },
artists: [{ id: "artist-1", name: "Shared Artist" }],
},
{
id: "track-2",
albumId: "album-2",
platform: "spotify",
platform_id: "spotify:track:two",
name: "Second Track",
popularity: 1,
duration: 240000,
explicit: false,
disc_number: 1,
track_number: 1,
album: { name: "Second Album", release_date: new Date("2010-01-01") },
artists: [{ id: "artist-1", name: "Shared Artist" }],
},
];
return {
query: {
track: {
findFirst: vi.fn(async () => tracks[0]),
findMany: vi.fn(async ({ where }: { where?: { name?: string } } = {}) =>
tracks.filter((track) => !where?.name || track.name === where.name),
),
},
artist: {
findFirst: vi.fn(async () => ({
id: "artist-1",
name: "Shared Artist",
})),
},
},
select: vi.fn(() => ({
from: () => ({
where: async () => [],
}),
})),
} as unknown as Db;
}
function makeSong(id: string, platformId: string, name: string): Song {
return {
id,
@ -234,6 +289,66 @@ describe("question generation", () => {
expect(question).toBeNull();
});
it("builds a numeric release-year question for a track", async () => {
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
const analytics = {
storyClusters: [
{
tracks: [
{
name: "First Track",
artists: [{ name: "Shared Artist" }],
albumName: "First Album",
},
{
name: "Second Track",
artists: [{ name: "Shared Artist" }],
albumName: "Second Album",
},
],
artists: [{ name: "Shared Artist" }],
genres: [],
},
],
groupSummary: { mostSharedGenres: [] },
} as PartyAnalytics;
try {
const question = await buildNumericQuestion({
db: createDetailedTrackDb(),
analytics,
index: 0,
members: [
{ userId: "a", name: "A" },
{ userId: "b", name: "B" },
],
history: [
{
questionIndex: 0,
question: {
type: "numeric",
text: "What's the release year of First Album?",
correct: 2001,
startTimestamp: 1,
endTimestamp: 2,
points: 10,
range: { min: 1991, max: 2011 },
questionKey: "numeric:album-year:First Album",
subjectKey: "album:First Album",
},
responses: [],
},
],
});
expect(question?.text).toBe('What year did "First Track" come out?');
expect(question?.correct).toBe(2001);
expect(question?.questionKey).toBe("numeric:track-year:First Track");
} finally {
randomSpy.mockRestore();
}
});
it("skips social fallback names for duplicate members", async () => {
const db = createFakeDb(null);
const members: PartyQuestionMember[] = [

View file

@ -14,6 +14,47 @@ import {
resolveQuestionSong,
} from "./question-utils";
type TrackDetails = {
id: string;
name: string | null;
platform_id: string | null;
duration: number | null;
album?: { name: string | null; release_date: Date | null } | null;
artists?: { name: string }[] | null;
};
async function getDetailedTopTracks(
dbClient: typeof Db,
analytics: PartyAnalytics,
): Promise<TrackDetails[]> {
const tracks: TrackDetails[] = [];
const seen = new Set<string>();
for (const topTrack of getTopClusterTracks(analytics)) {
const dbTracks = (await dbClient.query.track.findMany({
where: { name: topTrack.name },
with: { album: true, artists: true },
})) as TrackDetails[];
const sortedTracks = dbTracks.slice().sort((a, b) => {
const aArtists = a.artists?.map((artist) => artist.name) ?? [];
const bArtists = b.artists?.map((artist) => artist.name) ?? [];
const topArtists = topTrack.artists?.map((artist) => artist.name) ?? [];
const score = (track: typeof a, artistNames: string[]) =>
(track.album?.name === topTrack.albumName ? 2 : 0) +
(topArtists.some((name) => artistNames.includes(name)) ? 2 : 0);
return score(b, bArtists) - score(a, aArtists);
});
const track = sortedTracks[0];
if (!track || !isUsableText(track.name)) continue;
const key = track.platform_id ?? track.id;
if (seen.has(key)) continue;
seen.add(key);
tracks.push(track);
}
return tracks;
}
export async function buildAudioMetadataQuestion(
dbClient: typeof Db,
analytics: PartyAnalytics,
@ -114,6 +155,120 @@ export async function buildAudioMetadataQuestion(
}
}
for (const topTrack of topTracks) {
const correctArtist = topTrack.artists?.find((artist) =>
isUsableText(artist.name),
)?.name;
if (!topTrack.albumName || !correctArtist) continue;
const artistOptions = buildOptionsWithCorrect(correctArtist, topArtists, 4);
if (artistOptions) {
questions.push({
key: `audio:album-artist:${topTrack.albumName}:${correctArtist}`,
subjectKey: `album:${topTrack.albumName}`,
question: {
type: "choice",
text: `Which artist appears on "${topTrack.albumName}"?`,
options: artistOptions,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
});
}
}
const detailedTracks = await getDetailedTopTracks(dbClient, analytics);
const datedTracks = detailedTracks.filter(
(track) => track.album?.release_date,
);
if (datedTracks.length >= 2) {
const sortedByRelease = datedTracks
.slice()
.sort(
(a, b) =>
Number(a.album?.release_date ?? 0) -
Number(b.album?.release_date ?? 0),
);
const earliest = sortedByRelease[0];
const latest = sortedByRelease.at(-1);
const trackNames = sortedByRelease
.map((track) => track.name)
.filter((name): name is string => isUsableText(name));
if (earliest?.name) {
const options = buildOptionsWithCorrect(earliest.name, trackNames, 4);
if (options) {
questions.push({
key: `audio:release-first:${earliest.name}`,
subjectKey: `track:${earliest.name}`,
question: {
type: "choice",
text: "Which of these tracks came out first?",
options,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
});
}
}
if (latest?.name && latest.name !== earliest?.name) {
const options = buildOptionsWithCorrect(latest.name, trackNames, 4);
if (options) {
questions.push({
key: `audio:release-last:${latest.name}`,
subjectKey: `track:${latest.name}`,
question: {
type: "choice",
text: "Which of these tracks came out most recently?",
options,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
});
}
}
}
const tracksByArtist = new Map<string, TrackDetails[]>();
for (const track of detailedTracks) {
if (!track.duration) continue;
for (const artist of track.artists ?? []) {
if (!isUsableText(artist.name)) continue;
const artistTracks = tracksByArtist.get(artist.name) ?? [];
artistTracks.push(track);
tracksByArtist.set(artist.name, artistTracks);
}
}
for (const [artistName, artistTracks] of tracksByArtist) {
if (artistTracks.length < 2) continue;
const longest = artistTracks
.slice()
.sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0))[0];
if (!longest?.name) continue;
const trackNames = artistTracks
.map((track) => track.name)
.filter((name): name is string => isUsableText(name));
const options = buildOptionsWithCorrect(longest.name, trackNames, 4);
if (options) {
questions.push({
key: `audio:artist-longest-track:${artistName}:${longest.name}`,
subjectKey: `artist:${artistName}`,
question: {
type: "choice",
text: `What's the longest track by ${artistName}?`,
options,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
});
}
}
for (const topTrack of topTracks) {
const trackSong = await resolveQuestionSong(dbClient, analytics, {
trackName: topTrack.name,

View file

@ -21,6 +21,14 @@ type NumericQuestion = Omit<
"startTimestamp" | "endTimestamp"
>;
type TrackDetails = {
id: string;
name: string | null;
platform_id: string | null;
album?: { name: string | null; release_date: Date | null } | null;
artists?: { name: string }[] | null;
};
type BuildNumericQuestionInput = {
db: typeof db;
analytics: PartyAnalytics;
@ -29,6 +37,43 @@ type BuildNumericQuestionInput = {
history: QuizRound[];
};
async function getDetailedTopTracks({
db,
analytics,
}: BuildNumericQuestionInput): Promise<TrackDetails[]> {
const tracks: TrackDetails[] = [];
const seen = new Set<string>();
for (const topTrack of analytics?.storyClusters?.flatMap(
(cluster) => cluster.tracks ?? [],
) ?? []) {
if (!isUsableText(topTrack.name)) continue;
const dbTracks = (await db.query.track.findMany({
where: { name: topTrack.name },
with: { album: true, artists: true },
})) as TrackDetails[];
const topArtists = topTrack.artists?.map((artist) => artist.name) ?? [];
const track = dbTracks.slice().sort((a, b) => {
const score = (candidate: typeof a) => {
const artistNames =
candidate.artists?.map((artist) => artist.name) ?? [];
return (
(candidate.album?.name === topTrack.albumName ? 2 : 0) +
(topArtists.some((name) => artistNames.includes(name)) ? 2 : 0)
);
};
return score(b) - score(a);
})[0];
if (!track || !isUsableText(track.name)) continue;
const key = track.platform_id ?? track.id;
if (seen.has(key)) continue;
seen.add(key);
tracks.push(track);
}
return tracks;
}
async function getAlbumReleaseYear({
db,
analytics,
@ -61,6 +106,76 @@ async function getAlbumReleaseYear({
};
}
async function getTrackReleaseYear(
input: BuildNumericQuestionInput,
): Promise<NumericQuestion | null> {
const tracks = await getDetailedTopTracks(input);
const track = tracks.find((track) => track.album?.release_date && track.name);
if (!track?.name || !track.album?.release_date) return null;
const song = await resolveQuestionSong(input.db, input.analytics, {
trackName: track.name,
artistNames: track.artists?.map((artist) => artist.name),
albumName: track.album?.name ?? undefined,
});
const correct = track.album.release_date.getFullYear();
return {
type: "numeric",
text: `What year did "${track.name}" come out?`,
correct,
range: getReleaseYearRange(correct),
points: 10,
song: song ?? undefined,
questionKey: `numeric:track-year:${track.name}`,
subjectKey: `track:${track.name}`,
};
}
async function getArtistFirstTrackReleaseYear(
input: BuildNumericQuestionInput,
): Promise<NumericQuestion | null> {
const tracks = await getDetailedTopTracks(input);
const tracksByArtist = new Map<string, TrackDetails[]>();
for (const track of tracks) {
if (!track.album?.release_date) continue;
for (const artist of track.artists ?? []) {
if (!isUsableText(artist.name)) continue;
const artistTracks = tracksByArtist.get(artist.name) ?? [];
artistTracks.push(track);
tracksByArtist.set(artist.name, artistTracks);
}
}
const artistEntry = Array.from(tracksByArtist.entries()).find(
([, artistTracks]) => artistTracks.length >= 2,
);
if (!artistEntry) return null;
const [artistName, artistTracks] = artistEntry;
const firstTrack = artistTracks
.slice()
.sort(
(a, b) =>
Number(a.album?.release_date ?? 0) - Number(b.album?.release_date ?? 0),
)[0];
if (!firstTrack?.album?.release_date) return null;
const song = await resolveQuestionSong(input.db, input.analytics, {
trackName: firstTrack.name ?? undefined,
artistNames: [artistName],
albumName: firstTrack.album?.name ?? undefined,
});
const correct = firstTrack.album.release_date.getFullYear();
return {
type: "numeric",
text: `What year did ${artistName}'s first party track come out?`,
correct,
range: getReleaseYearRange(correct),
points: 10,
song: song ?? undefined,
questionKey: `numeric:artist-first-track-year:${artistName}`,
subjectKey: `artist:${artistName}`,
};
}
async function countTopTrackListeners({
db,
analytics,
@ -149,6 +264,26 @@ export async function buildNumericQuestion(
});
}
const trackYearQ = await getTrackReleaseYear(input);
if (trackYearQ) {
questions.push({
key: trackYearQ.questionKey ?? `numeric:track-year:${trackYearQ.text}`,
subjectKey: trackYearQ.subjectKey,
question: trackYearQ,
});
}
const artistFirstTrackYearQ = await getArtistFirstTrackReleaseYear(input);
if (artistFirstTrackYearQ) {
questions.push({
key:
artistFirstTrackYearQ.questionKey ??
`numeric:artist-first-track-year:${artistFirstTrackYearQ.text}`,
subjectKey: artistFirstTrackYearQ.subjectKey,
question: artistFirstTrackYearQ,
});
}
const topTrackQ = await countTopTrackListeners(input);
if (topTrackQ) {
questions.push({

View file

@ -1,4 +1,12 @@
import { useParty } from "#/hooks/use-party";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "../ui/empty";
import { Spinner } from "../ui/spinner";
import { Question } from "./question";
import { QuestionReview } from "./question-review";
import { Results } from "./results";
@ -6,7 +14,26 @@ import { SpotifyPlayback } from "./spotify-playback";
export function PartyView() {
const { party } = useParty();
if (!party?.data) return null;
if (!party) return null;
if (!party.data) {
if (party.status !== "started") return null;
return (
<Empty>
<EmptyHeader>
<EmptyTitle>Preparing party</EmptyTitle>
<EmptyDescription>
Analyzing everyone's music taste and building the first question.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex items-center gap-2 text-muted-foreground">
<Spinner />
<span>This can take a moment.</span>
</div>
</EmptyContent>
</Empty>
);
}
switch (party.data.status) {
case "running":

View file

@ -20,8 +20,10 @@ function App() {
<DeviceChoice />
{!user?.lastSyncAt && <SyncButton />}
{user && party?.data?.status !== "running" && <PartyQr />}
{party && !party.data && members.length > 1 && <StartParty />}
{party?.data && <PartyView />}
{party?.status === "created" && !party.data && members.length > 1 && (
<StartParty />
)}
{party && (party.data || party.status === "started") && <PartyView />}
</MainContent>
);
}