more questions
This commit is contained in:
parent
2154c0b6d1
commit
3b7d669a5d
5 changed files with 437 additions and 3 deletions
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue