From 2c26d7e911fa48adbbca9cbcb2c234031222a486 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 16:10:36 +0100 Subject: [PATCH 1/6] feat(wrapped): add pure aggregation logic and types Add period-based stats computation for quarterly/annual wrapped. Includes filterRoundsByPeriod, getQuarter, computeWrappedStats, getAvailablePeriods, and formatPeriodLabel as pure testable functions. 39 tests covering edge cases, filtering, and sorting. --- src/lib/__tests__/wrapped.test.ts | 370 ++++++++++++++++++++++++++++++ src/lib/wrapped.ts | 217 ++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 src/lib/__tests__/wrapped.test.ts create mode 100644 src/lib/wrapped.ts diff --git a/src/lib/__tests__/wrapped.test.ts b/src/lib/__tests__/wrapped.test.ts new file mode 100644 index 0000000..1aa3bef --- /dev/null +++ b/src/lib/__tests__/wrapped.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect } from 'vitest'; +import type { Member, Round, Song, Vote } from '../types'; +import { + getQuarter, + filterRoundsByPeriod, + computeWrappedStats, + getAvailablePeriods, + formatPeriodLabel, + type WrappedPeriod, +} from '../wrapped'; + +// --- Factories --- + +function makeRound(overrides: Partial = {}): Round { + return { + id: 'round-1', + group_id: 'group-1', + number: 1, + starts_at: '2026-01-15T00:00:00Z', + ends_at: '2026-01-22T00:00:00Z', + created_at: '2026-01-15T00:00:00Z', + ...overrides, + }; +} + +function makeSong(overrides: Partial = {}): Song { + return { + id: 'song-1', + round_id: 'round-1', + member_id: 'member-1', + title: 'Test Song', + artist: 'Test Artist', + album: null, + thumbnail_url: null, + platform_links: [], + odesli_page_url: null, + genre: 'rock', + created_at: '2026-01-15T00:00:00Z', + ...overrides, + }; +} + +function makeVote(overrides: Partial = {}): Vote { + return { + id: 'vote-1', + song_id: 'song-1', + member_id: 'member-2', + rating: 4, + created_at: '2026-01-16T00:00:00Z', + ...overrides, + }; +} + +function makeMember(overrides: Partial = {}): Member { + return { + id: 'member-1', + group_id: 'group-1', + name: 'Alice', + avatar: '🎵', + is_admin: false, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +// --- Tests --- + +describe('getQuarter', () => { + it('returns 1 for January', () => { + expect(getQuarter(new Date('2026-01-15'))).toBe(1); + }); + + it('returns 1 for March', () => { + expect(getQuarter(new Date('2026-03-31'))).toBe(1); + }); + + it('returns 2 for April', () => { + expect(getQuarter(new Date('2026-04-01'))).toBe(2); + }); + + it('returns 2 for June', () => { + expect(getQuarter(new Date('2026-06-30'))).toBe(2); + }); + + it('returns 3 for July', () => { + expect(getQuarter(new Date('2026-07-01'))).toBe(3); + }); + + it('returns 4 for October', () => { + expect(getQuarter(new Date('2026-10-15'))).toBe(4); + }); + + it('returns 4 for December', () => { + expect(getQuarter(new Date('2026-12-31'))).toBe(4); + }); +}); + +describe('filterRoundsByPeriod', () => { + const rounds: Round[] = [ + makeRound({ id: 'r1', starts_at: '2026-01-15T00:00:00Z' }), + makeRound({ id: 'r2', starts_at: '2026-02-15T00:00:00Z' }), + makeRound({ id: 'r3', starts_at: '2026-04-15T00:00:00Z' }), + makeRound({ id: 'r4', starts_at: '2026-07-15T00:00:00Z' }), + makeRound({ id: 'r5', starts_at: '2025-10-15T00:00:00Z' }), + ]; + + it('filters by Q1 correctly', () => { + const result = filterRoundsByPeriod(rounds, { type: 'quarterly', year: 2026, quarter: 1 }); + expect(result.map((r) => r.id)).toEqual(['r1', 'r2']); + }); + + it('filters by Q2 correctly', () => { + const result = filterRoundsByPeriod(rounds, { type: 'quarterly', year: 2026, quarter: 2 }); + expect(result.map((r) => r.id)).toEqual(['r3']); + }); + + it('filters by Q3 correctly', () => { + const result = filterRoundsByPeriod(rounds, { type: 'quarterly', year: 2026, quarter: 3 }); + expect(result.map((r) => r.id)).toEqual(['r4']); + }); + + it('filters by Q4 of different year', () => { + const result = filterRoundsByPeriod(rounds, { type: 'quarterly', year: 2025, quarter: 4 }); + expect(result.map((r) => r.id)).toEqual(['r5']); + }); + + it('filters by year for annual period', () => { + const result = filterRoundsByPeriod(rounds, { type: 'annual', year: 2026 }); + expect(result.map((r) => r.id)).toEqual(['r1', 'r2', 'r3', 'r4']); + }); + + it('returns empty for no matching rounds', () => { + const result = filterRoundsByPeriod(rounds, { type: 'quarterly', year: 2024, quarter: 1 }); + expect(result).toEqual([]); + }); + + it('handles empty rounds array', () => { + const result = filterRoundsByPeriod([], { type: 'annual', year: 2026 }); + expect(result).toEqual([]); + }); +}); + +describe('computeWrappedStats', () => { + const members: Member[] = [ + makeMember({ id: 'm1', name: 'Alice', avatar: '🎵' }), + makeMember({ id: 'm2', name: 'Bob', avatar: '🎸' }), + ]; + + const rounds: Round[] = [ + makeRound({ id: 'r1', number: 1, starts_at: '2026-01-10T00:00:00Z' }), + makeRound({ id: 'r2', number: 2, starts_at: '2026-02-10T00:00:00Z' }), + ]; + + const songs: Song[] = [ + makeSong({ id: 's1', round_id: 'r1', member_id: 'm1', genre: 'rock' }), + makeSong({ id: 's2', round_id: 'r1', member_id: 'm2', genre: 'pop' }), + makeSong({ id: 's3', round_id: 'r2', member_id: 'm1', genre: 'rock' }), + makeSong({ id: 's4', round_id: 'r2', member_id: 'm2', genre: 'jazz' }), + ]; + + const votes: Vote[] = [ + makeVote({ id: 'v1', song_id: 's1', member_id: 'm2', rating: 4 }), + makeVote({ id: 'v2', song_id: 's2', member_id: 'm1', rating: 3 }), + makeVote({ id: 'v3', song_id: 's3', member_id: 'm2', rating: 5 }), + makeVote({ id: 'v4', song_id: 's4', member_id: 'm1', rating: 2 }), + ]; + + const period: WrappedPeriod = { type: 'quarterly', year: 2026, quarter: 1 }; + + it('computes totalSongs correctly', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + expect(stats.totalSongs).toBe(4); + }); + + it('computes totalRounds correctly', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + expect(stats.totalRounds).toBe(2); + }); + + it('computes totalVotes correctly', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + expect(stats.totalVotes).toBe(4); + }); + + it('identifies topSong by highest avgRating', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + expect(stats.topSong).not.toBeNull(); + expect(stats.topSong!.id).toBe('s3'); + expect(stats.topSong!.avgRating).toBe(5); + }); + + it('sorts topMembers by totalScore desc', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + expect(stats.topMembers[0].name).toBe('Alice'); + expect(stats.topMembers[0].totalScore).toBe(9); // 4 + 5 + expect(stats.topMembers[1].name).toBe('Bob'); + expect(stats.topMembers[1].totalScore).toBe(5); // 3 + 2 + }); + + it('computes member songsAdded correctly', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + const alice = stats.topMembers.find((m) => m.name === 'Alice')!; + expect(alice.songsAdded).toBe(2); + }); + + it('computes member avgReceived correctly', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + const alice = stats.topMembers.find((m) => m.name === 'Alice')!; + expect(alice.avgReceived).toBe(4.5); // (4 + 5) / 2 + }); + + it('computes roundsWon correctly', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + const alice = stats.topMembers.find((m) => m.name === 'Alice')!; + expect(alice.roundsWon).toBe(2); // won both rounds + }); + + it('computes genreDistribution sorted by count', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + expect(stats.genreDistribution[0]).toEqual({ genre: 'rock', count: 2 }); + expect(stats.genreDistribution).toHaveLength(3); + }); + + it('identifies topGenre as most common genre', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + expect(stats.topGenre).toBe('rock'); + }); + + it('returns topSongs sorted by avgRating desc, max 5', () => { + const stats = computeWrappedStats(period, rounds, songs, votes, members); + expect(stats.topSongs.length).toBeLessThanOrEqual(5); + for (let i = 1; i < stats.topSongs.length; i++) { + expect(stats.topSongs[i - 1].avgRating).toBeGreaterThanOrEqual(stats.topSongs[i].avgRating); + } + }); + + it('handles empty data gracefully', () => { + const stats = computeWrappedStats(period, [], [], [], []); + expect(stats.totalSongs).toBe(0); + expect(stats.totalRounds).toBe(0); + expect(stats.totalVotes).toBe(0); + expect(stats.topSong).toBeNull(); + expect(stats.topMembers).toEqual([]); + expect(stats.topSongs).toEqual([]); + expect(stats.genreDistribution).toEqual([]); + expect(stats.topGenre).toBeNull(); + }); + + it('ignores songs with null genre in genreDistribution', () => { + const songsWithNull: Song[] = [ + makeSong({ id: 's1', round_id: 'r1', member_id: 'm1', genre: null }), + makeSong({ id: 's2', round_id: 'r1', member_id: 'm2', genre: 'pop' }), + ]; + const stats = computeWrappedStats(period, rounds, songsWithNull, [], members); + expect(stats.genreDistribution).toEqual([{ genre: 'pop', count: 1 }]); + }); + + it('returns null topSong when no songs have votes', () => { + const stats = computeWrappedStats(period, rounds, songs, [], members); + expect(stats.topSong).toBeNull(); + }); + + it('excludes songs from rounds outside the period', () => { + const q2Round = makeRound({ id: 'r-q2', number: 3, starts_at: '2026-04-15T00:00:00Z' }); + const q2Song = makeSong({ id: 's-q2', round_id: 'r-q2', member_id: 'm1', genre: 'jazz' }); + const q2Vote = makeVote({ id: 'v-q2', song_id: 's-q2', member_id: 'm2', rating: 5 }); + + const allRounds = [...rounds, q2Round]; + const allSongs = [...songs, q2Song]; + const allVotes = [...votes, q2Vote]; + + const stats = computeWrappedStats(period, allRounds, allSongs, allVotes, members); + // Q1 period should NOT include the Q2 round/song/vote + expect(stats.totalSongs).toBe(4); + expect(stats.totalRounds).toBe(2); + expect(stats.totalVotes).toBe(4); + }); + + it('computes avg correctly with multiple votes per song', () => { + const multiVotes: Vote[] = [ + makeVote({ id: 'v1', song_id: 's1', member_id: 'm2', rating: 3 }), + makeVote({ id: 'v2', song_id: 's1', member_id: 'm1', rating: 4 }), + makeVote({ id: 'v3', song_id: 's1', member_id: 'm2', rating: 5 }), + ]; + const oneSong: Song[] = [makeSong({ id: 's1', round_id: 'r1', member_id: 'm1' })]; + const stats = computeWrappedStats(period, rounds, oneSong, multiVotes, members); + expect(stats.topSong!.avgRating).toBe(4); // (3 + 4 + 5) / 3 + expect(stats.topSong!.totalVotes).toBe(3); + }); + + it('caps topSongs at 5 with more than 5 rated songs', () => { + const manySongs: Song[] = Array.from({ length: 7 }, (_, i) => + makeSong({ id: `s${i}`, round_id: 'r1', member_id: 'm1', genre: 'rock' }), + ); + const manyVotes: Vote[] = manySongs.map((s, i) => + makeVote({ id: `v${i}`, song_id: s.id, member_id: 'm2', rating: 5 - i * 0.5 }), + ); + const stats = computeWrappedStats(period, rounds, manySongs, manyVotes, members); + expect(stats.topSongs).toHaveLength(5); + }); + + it('returns zero stats for member with songs only outside period', () => { + const q2Round = makeRound({ id: 'r-q2', number: 3, starts_at: '2026-04-15T00:00:00Z' }); + const q2Song = makeSong({ id: 's-q2', round_id: 'r-q2', member_id: 'm2' }); + const stats = computeWrappedStats( + period, + [q2Round], + [q2Song], + [], + [makeMember({ id: 'm2', name: 'Bob' })], + ); + expect(stats.topMembers[0].songsAdded).toBe(0); + expect(stats.topMembers[0].avgReceived).toBe(0); + expect(stats.topMembers[0].roundsWon).toBe(0); + }); +}); + +describe('getAvailablePeriods', () => { + it('generates quarterly and annual periods', () => { + const rounds: Round[] = [ + makeRound({ id: 'r1', starts_at: '2026-01-15T00:00:00Z' }), + makeRound({ id: 'r2', starts_at: '2026-04-15T00:00:00Z' }), + ]; + const periods = getAvailablePeriods(rounds); + expect(periods).toHaveLength(3); // Q1 2026, Q2 2026, 2026 + }); + + it('orders most recent first', () => { + const rounds: Round[] = [ + makeRound({ id: 'r1', starts_at: '2025-07-15T00:00:00Z' }), + makeRound({ id: 'r2', starts_at: '2026-01-15T00:00:00Z' }), + ]; + const periods = getAvailablePeriods(rounds); + // 2026: Q1 2026, 2026, Q3 2025, 2025 + expect(periods[0]).toEqual({ type: 'quarterly', year: 2026, quarter: 1 }); + expect(periods[1]).toEqual({ type: 'annual', year: 2026 }); + expect(periods[2]).toEqual({ type: 'quarterly', year: 2025, quarter: 3 }); + expect(periods[3]).toEqual({ type: 'annual', year: 2025 }); + }); + + it('returns empty for no rounds', () => { + expect(getAvailablePeriods([])).toEqual([]); + }); + + it('deduplicates quarters from multiple rounds in same quarter', () => { + const rounds: Round[] = [ + makeRound({ id: 'r1', starts_at: '2026-01-10T00:00:00Z' }), + makeRound({ id: 'r2', starts_at: '2026-02-10T00:00:00Z' }), + ]; + const periods = getAvailablePeriods(rounds); + const q1Count = periods.filter( + (p) => p.type === 'quarterly' && p.year === 2026 && p.quarter === 1, + ).length; + expect(q1Count).toBe(1); + }); +}); + +describe('formatPeriodLabel', () => { + it('formats quarterly period', () => { + expect(formatPeriodLabel({ type: 'quarterly', year: 2026, quarter: 1 })).toBe('Q1 2026'); + }); + + it('formats Q4 period', () => { + expect(formatPeriodLabel({ type: 'quarterly', year: 2025, quarter: 4 })).toBe('Q4 2025'); + }); + + it('formats annual period', () => { + expect(formatPeriodLabel({ type: 'annual', year: 2025 })).toBe('2025'); + }); +}); diff --git a/src/lib/wrapped.ts b/src/lib/wrapped.ts new file mode 100644 index 0000000..4c7ee24 --- /dev/null +++ b/src/lib/wrapped.ts @@ -0,0 +1,217 @@ +import type { Member, Round, Song, Vote } from './types'; + +export type Quarter = 1 | 2 | 3 | 4; + +export type WrappedPeriod = + | { type: 'quarterly'; year: number; quarter: Quarter } + | { type: 'annual'; year: number }; + +export interface WrappedSong { + id: string; + title: string; + artist: string; + thumbnail_url: string | null; + genre: string | null; + memberName: string; + avgRating: number; + totalVotes: number; +} + +export interface WrappedMember { + id: string; + name: string; + avatar: string; + totalScore: number; + songsAdded: number; + avgReceived: number; + roundsWon: number; +} + +export interface GenreCount { + genre: string; + count: number; +} + +export interface WrappedStats { + period: WrappedPeriod; + totalSongs: number; + totalRounds: number; + totalVotes: number; + topSong: WrappedSong | null; + topMembers: WrappedMember[]; + topSongs: WrappedSong[]; + genreDistribution: GenreCount[]; + topGenre: string | null; +} + +/** Get the quarter (1-4) from a date. */ +export function getQuarter(date: Date): Quarter { + return (Math.floor(date.getMonth() / 3) + 1) as Quarter; +} + +/** Filter rounds that fall within the given period. */ +export function filterRoundsByPeriod(rounds: Round[], period: WrappedPeriod): Round[] { + return rounds.filter((round) => { + const date = new Date(round.starts_at); + const year = date.getFullYear(); + if (period.type === 'annual') return year === period.year; + return year === period.year && getQuarter(date) === period.quarter; + }); +} + +/** Compute all wrapped stats from raw data. */ +export function computeWrappedStats( + period: WrappedPeriod, + rounds: Round[], + songs: Song[], + votes: Vote[], + members: Member[], +): WrappedStats { + const periodRounds = filterRoundsByPeriod(rounds, period); + const roundIds = new Set(periodRounds.map((r) => r.id)); + + const periodSongs = songs.filter((s) => roundIds.has(s.round_id)); + const songIds = new Set(periodSongs.map((s) => s.id)); + const periodVotes = votes.filter((v) => songIds.has(v.song_id)); + + // Compute avg rating per song + const songStats = periodSongs.map((song) => { + const songVotes = periodVotes.filter((v) => v.song_id === song.id); + const total = songVotes.length; + const avg = total > 0 ? songVotes.reduce((sum, v) => sum + v.rating, 0) / total : 0; + const memberName = members.find((m) => m.id === song.member_id)?.name ?? '?'; + return { + id: song.id, + title: song.title, + artist: song.artist, + thumbnail_url: song.thumbnail_url, + genre: song.genre, + memberName, + avgRating: avg, + totalVotes: total, + }; + }); + + // Top song = highest avgRating with at least 1 vote + const ratedSongs = songStats.filter((s) => s.totalVotes > 0); + const sortedByRating = [...ratedSongs].sort((a, b) => b.avgRating - a.avgRating); + const topSong = sortedByRating[0] ?? null; + const topSongs = sortedByRating.slice(0, 5); + + // Find round winners (highest avg rating per round) + const roundWins = new Map(); + for (const round of periodRounds) { + const roundSongs = songStats.filter((s) => { + const original = periodSongs.find((ps) => ps.id === s.id); + return original?.round_id === round.id; + }); + const rated = roundSongs.filter((s) => s.totalVotes > 0); + if (rated.length === 0) continue; + const winner = rated.reduce((best, s) => (s.avgRating > best.avgRating ? s : best)); + const winnerId = periodSongs.find((s) => s.id === winner.id)?.member_id; + if (winnerId) { + roundWins.set(winnerId, (roundWins.get(winnerId) ?? 0) + 1); + } + } + + // Compute member stats + const memberStats: WrappedMember[] = members.map((member) => { + const memberSongs = periodSongs.filter((s) => s.member_id === member.id); + let totalRatingsSum = 0; + let totalRatingsCount = 0; + for (const song of memberSongs) { + const songVotes = periodVotes.filter((v) => v.song_id === song.id); + for (const vote of songVotes) { + totalRatingsSum += vote.rating; + totalRatingsCount++; + } + } + return { + id: member.id, + name: member.name, + avatar: member.avatar, + totalScore: totalRatingsSum, + songsAdded: memberSongs.length, + avgReceived: totalRatingsCount > 0 ? totalRatingsSum / totalRatingsCount : 0, + roundsWon: roundWins.get(member.id) ?? 0, + }; + }); + memberStats.sort((a, b) => b.totalScore - a.totalScore); + + // Genre distribution + const genreCounts = new Map(); + for (const song of periodSongs) { + if (song.genre) { + genreCounts.set(song.genre, (genreCounts.get(song.genre) ?? 0) + 1); + } + } + const genreDistribution: GenreCount[] = [...genreCounts.entries()] + .map(([genre, count]) => ({ genre, count })) + .sort((a, b) => b.count - a.count); + + const topGenre = genreDistribution[0]?.genre ?? null; + + return { + period, + totalSongs: periodSongs.length, + totalRounds: periodRounds.length, + totalVotes: periodVotes.length, + topSong, + topMembers: memberStats, + topSongs, + genreDistribution, + topGenre, + }; +} + +/** Get available periods for a group given its rounds. */ +export function getAvailablePeriods(rounds: Round[]): WrappedPeriod[] { + const quarterSet = new Set(); + const yearSet = new Set(); + + for (const round of rounds) { + const date = new Date(round.starts_at); + const year = date.getFullYear(); + const quarter = getQuarter(date); + quarterSet.add(`${year}-${quarter}`); + yearSet.add(year); + } + + const periods: WrappedPeriod[] = []; + + // Quarterly periods + for (const key of quarterSet) { + const [yearStr, quarterStr] = key.split('-'); + periods.push({ + type: 'quarterly', + year: Number(yearStr), + quarter: Number(quarterStr) as Quarter, + }); + } + + // Annual periods + for (const year of yearSet) { + periods.push({ type: 'annual', year }); + } + + // Sort: most recent first. Annual after quarterly for same year. + periods.sort((a, b) => { + const yearA = a.year; + const yearB = b.year; + if (yearA !== yearB) return yearB - yearA; + // Same year: quarterly before annual + if (a.type === 'quarterly' && b.type === 'annual') return -1; + if (a.type === 'annual' && b.type === 'quarterly') return 1; + // Both quarterly: higher quarter first + if (a.type === 'quarterly' && b.type === 'quarterly') return b.quarter - a.quarter; + return 0; + }); + + return periods; +} + +/** Format period label: "Q1 2026" or "2025". */ +export function formatPeriodLabel(period: WrappedPeriod): string { + if (period.type === 'annual') return String(period.year); + return `Q${period.quarter} ${period.year}`; +} From 29297f1a1416b4507bd2a41c4d61816769e665c9 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 16:11:09 +0100 Subject: [PATCH 2/6] feat(wrapped): add useWrapped hook for data fetching Fetches members, rounds, songs, and votes from Supabase and computes wrapped stats using pure functions. Supports period selection without re-fetching. 10 tests covering success, errors, and edge cases. --- .../wrapped/__tests__/useWrapped.test.ts | 288 ++++++++++++++++++ src/features/wrapped/useWrapped.ts | 122 ++++++++ 2 files changed, 410 insertions(+) create mode 100644 src/features/wrapped/__tests__/useWrapped.test.ts create mode 100644 src/features/wrapped/useWrapped.ts diff --git a/src/features/wrapped/__tests__/useWrapped.test.ts b/src/features/wrapped/__tests__/useWrapped.test.ts new file mode 100644 index 0000000..99d798f --- /dev/null +++ b/src/features/wrapped/__tests__/useWrapped.test.ts @@ -0,0 +1,288 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useWrapped } from '../useWrapped'; +import type { Member, Round, Song, Vote } from '../../../lib/types'; + +// --- Configurable supabase mock --- + +let mockData: { + members: { data: Member[] | null; error: unknown }; + rounds: { data: Round[] | null; error: unknown }; + songs: { data: Song[] | null; error: unknown }; + votes: { data: Vote[] | null; error: unknown }; +}; + +function resetMockData() { + mockData = { + members: { data: [], error: null }, + rounds: { data: [], error: null }, + songs: { data: [], error: null }, + votes: { data: [], error: null }, + }; +} + +vi.mock('../../../lib/supabase', () => ({ + supabase: { + from: (table: string) => { + const tableData = () => { + switch (table) { + case 'members': + return mockData.members; + case 'rounds': + return mockData.rounds; + case 'songs': + return mockData.songs; + case 'votes': + return mockData.votes; + default: + return { data: [], error: null }; + } + }; + + const chain: Record = {}; + const resolve = () => Promise.resolve(tableData()); + + chain.select = () => chain; + chain.eq = () => chain; + chain.in = () => resolve(); + chain.order = () => resolve(); + chain.then = (fn: (v: unknown) => void) => resolve().then(fn); + + return chain; + }, + }, +})); + +// --- Factories --- + +function makeMember(overrides: Partial = {}): Member { + return { + id: 'member-1', + group_id: 'group-1', + name: 'Alice', + avatar: '🎵', + is_admin: false, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function makeRound(overrides: Partial = {}): Round { + return { + id: 'round-1', + group_id: 'group-1', + number: 1, + starts_at: '2026-01-15T00:00:00Z', + ends_at: '2026-01-22T00:00:00Z', + created_at: '2026-01-15T00:00:00Z', + ...overrides, + }; +} + +function makeSong(overrides: Partial = {}): Song { + return { + id: 'song-1', + round_id: 'round-1', + member_id: 'member-1', + title: 'Test Song', + artist: 'Test Artist', + album: null, + thumbnail_url: null, + platform_links: [], + odesli_page_url: null, + genre: 'rock', + created_at: '2026-01-15T00:00:00Z', + ...overrides, + }; +} + +function makeVote(overrides: Partial = {}): Vote { + return { + id: 'vote-1', + song_id: 'song-1', + member_id: 'member-2', + rating: 4, + created_at: '2026-01-16T00:00:00Z', + ...overrides, + }; +} + +describe('useWrapped', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetMockData(); + }); + + it('should start in loading state', () => { + const { result } = renderHook(() => useWrapped('group-1')); + expect(result.current.isLoading).toBe(true); + expect(result.current.stats).toBeNull(); + expect(result.current.availablePeriods).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('should return stats for default period after fetch', async () => { + const alice = makeMember({ id: 'm1', name: 'Alice' }); + const round = makeRound({ id: 'r1', starts_at: '2026-01-15T00:00:00Z' }); + const song = makeSong({ id: 's1', round_id: 'r1', member_id: 'm1' }); + const vote = makeVote({ id: 'v1', song_id: 's1', member_id: 'm2', rating: 4 }); + + mockData.members = { data: [alice], error: null }; + mockData.rounds = { data: [round], error: null }; + mockData.songs = { data: [song], error: null }; + mockData.votes = { data: [vote], error: null }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.stats).not.toBeNull(); + expect(result.current.stats!.totalSongs).toBe(1); + expect(result.current.stats!.totalVotes).toBe(1); + expect(result.current.selectedPeriod).not.toBeNull(); + }); + + it('should return available periods', async () => { + const r1 = makeRound({ id: 'r1', starts_at: '2026-01-15T00:00:00Z' }); + const r2 = makeRound({ id: 'r2', starts_at: '2026-04-15T00:00:00Z' }); + + mockData.members = { data: [], error: null }; + mockData.rounds = { data: [r2, r1], error: null }; + mockData.songs = { data: [], error: null }; + mockData.votes = { data: [], error: null }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Q2 2026, Q1 2026, 2026 + expect(result.current.availablePeriods).toHaveLength(3); + }); + + it('should change period without re-fetching', async () => { + const alice = makeMember({ id: 'm1', name: 'Alice' }); + const r1 = makeRound({ id: 'r1', number: 1, starts_at: '2026-01-15T00:00:00Z' }); + const r2 = makeRound({ id: 'r2', number: 2, starts_at: '2026-04-15T00:00:00Z' }); + const s1 = makeSong({ id: 's1', round_id: 'r1', member_id: 'm1' }); + const s2 = makeSong({ id: 's2', round_id: 'r2', member_id: 'm1' }); + + mockData.members = { data: [alice], error: null }; + mockData.rounds = { data: [r2, r1], error: null }; + mockData.songs = { data: [s1, s2], error: null }; + mockData.votes = { data: [], error: null }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Default is most recent (Q2 2026) + expect(result.current.stats!.totalSongs).toBe(1); + + // Switch to Q1 2026 + act(() => { + result.current.setSelectedPeriod({ type: 'quarterly', year: 2026, quarter: 1 }); + }); + + expect(result.current.stats!.totalSongs).toBe(1); + expect(result.current.stats!.period).toEqual({ type: 'quarterly', year: 2026, quarter: 1 }); + }); + + it('should handle errors', async () => { + mockData.members = { data: null, error: { message: 'connection refused' } }; + mockData.rounds = { data: [], error: null }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toContain('Failed to fetch members'); + }); + + it('should handle no rounds gracefully', async () => { + mockData.members = { data: [makeMember()], error: null }; + mockData.rounds = { data: [], error: null }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.availablePeriods).toEqual([]); + expect(result.current.selectedPeriod).toBeNull(); + expect(result.current.stats).toBeNull(); + }); + + it('should default to the most recent period', async () => { + const r1 = makeRound({ id: 'r1', starts_at: '2025-07-15T00:00:00Z' }); + const r2 = makeRound({ id: 'r2', starts_at: '2026-01-15T00:00:00Z' }); + + mockData.members = { data: [], error: null }; + mockData.rounds = { data: [r2, r1], error: null }; + mockData.songs = { data: [], error: null }; + mockData.votes = { data: [], error: null }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Most recent should be Q1 2026 + expect(result.current.selectedPeriod).toEqual({ type: 'quarterly', year: 2026, quarter: 1 }); + }); + + it('should handle rounds fetch error', async () => { + mockData.members = { data: [], error: null }; + mockData.rounds = { data: null, error: { message: 'timeout' } }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toContain('Failed to fetch rounds'); + }); + + it('should handle songs fetch error', async () => { + const r1 = makeRound({ id: 'r1' }); + mockData.members = { data: [], error: null }; + mockData.rounds = { data: [r1], error: null }; + mockData.songs = { data: null, error: { message: 'timeout' } }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toContain('Failed to fetch songs'); + }); + + it('should skip votes fetch when no songs exist', async () => { + const r1 = makeRound({ id: 'r1' }); + mockData.members = { data: [makeMember()], error: null }; + mockData.rounds = { data: [r1], error: null }; + mockData.songs = { data: [], error: null }; + mockData.votes = { data: [], error: null }; + + const { result } = renderHook(() => useWrapped('group-1')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.stats).not.toBeNull(); + }); +}); diff --git a/src/features/wrapped/useWrapped.ts b/src/features/wrapped/useWrapped.ts new file mode 100644 index 0000000..1e4f441 --- /dev/null +++ b/src/features/wrapped/useWrapped.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { supabase } from '../../lib/supabase'; +import type { Member, Round, Song, Vote } from '../../lib/types'; +import { + computeWrappedStats, + getAvailablePeriods, + type WrappedPeriod, + type WrappedStats, +} from '../../lib/wrapped'; + +export interface UseWrappedResult { + stats: WrappedStats | null; + availablePeriods: WrappedPeriod[]; + selectedPeriod: WrappedPeriod | null; + setSelectedPeriod: (period: WrappedPeriod) => void; + isLoading: boolean; + error: string | null; +} + +export function useWrapped(groupId: string): UseWrappedResult { + const [members, setMembers] = useState([]); + const [rounds, setRounds] = useState([]); + const [songs, setSongs] = useState([]); + const [votes, setVotes] = useState([]); + const [selectedPeriod, setSelectedPeriod] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const mountedRef = useRef(true); + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const fetchData = useCallback(async () => { + if (!mountedRef.current) return; + setIsLoading(true); + setError(null); + + try { + const [membersRes, roundsRes] = await Promise.all([ + supabase.from('members').select('*').eq('group_id', groupId), + supabase + .from('rounds') + .select('*') + .eq('group_id', groupId) + .order('number', { ascending: false }), + ]); + + if (membersRes.error) throw new Error(`Failed to fetch members: ${membersRes.error.message}`); + if (roundsRes.error) throw new Error(`Failed to fetch rounds: ${roundsRes.error.message}`); + + const allMembers = (membersRes.data ?? []) as Member[]; + const allRounds = (roundsRes.data ?? []) as Round[]; + const roundIds = allRounds.map((r) => r.id); + + if (roundIds.length === 0) { + if (!mountedRef.current) return; + setMembers(allMembers); + setRounds([]); + setSongs([]); + setVotes([]); + return; + } + + const { data: songsData, error: songsError } = await supabase + .from('songs') + .select('*') + .in('round_id', roundIds); + + if (songsError) throw new Error(`Failed to fetch songs: ${songsError.message}`); + + const allSongs = (songsData ?? []) as Song[]; + const songIds = allSongs.map((s) => s.id); + + let allVotes: Vote[] = []; + if (songIds.length > 0) { + const { data: votesData, error: votesError } = await supabase + .from('votes') + .select('*') + .in('song_id', songIds); + + if (votesError) throw new Error(`Failed to fetch votes: ${votesError.message}`); + allVotes = (votesData ?? []) as Vote[]; + } + + if (!mountedRef.current) return; + setMembers(allMembers); + setRounds(allRounds); + setSongs(allSongs); + setVotes(allVotes); + + // Default to most recent period + const periods = getAvailablePeriods(allRounds); + if (periods.length > 0) { + setSelectedPeriod((prev) => prev ?? periods[0]); + } + } catch (err) { + if (!mountedRef.current) return; + const message = err instanceof Error ? err.message : 'Failed to load wrapped data'; + setError(message); + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, [groupId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const availablePeriods = useMemo(() => getAvailablePeriods(rounds), [rounds]); + + const stats = useMemo(() => { + if (!selectedPeriod) return null; + return computeWrappedStats(selectedPeriod, rounds, songs, votes, members); + }, [selectedPeriod, rounds, songs, votes, members]); + + return { stats, availablePeriods, selectedPeriod, setSelectedPeriod, isLoading, error }; +} From 378a5bce303f3bc45e29e028ed098ece282e9be9 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 16:11:21 +0100 Subject: [PATCH 3/6] feat(wrapped): add i18n keys for EN and ES Add 16 translation keys for the wrapped feature including titles, labels, period subtitles, and empty state messages. --- src/i18n/en.json | 15 +++++++++++++++ src/i18n/es.json | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/i18n/en.json b/src/i18n/en.json index 6f8379f..b52af2b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -89,6 +89,21 @@ "leaderboard.genreFilter": "Filter by genre", "leaderboard.allGenres": "All genres", "leaderboard.fullLink": "Full leaderboard", + "wrapped.title": "Wrapped", + "wrapped.subtitle.quarterly": "Q{quarter} {year}", + "wrapped.subtitle.annual": "{year} in review", + "wrapped.overview": "Overview", + "wrapped.songs": "songs shared", + "wrapped.rounds": "rounds played", + "wrapped.votes": "votes cast", + "wrapped.songOfPeriod": "Song of the quarter", + "wrapped.songOfYear": "Song of the year", + "wrapped.topMembers": "Top members", + "wrapped.topSongs": "Top 5 songs", + "wrapped.genres": "Genres", + "wrapped.noData": "Not enough data for this period yet", + "wrapped.selectPeriod": "Select period", + "wrapped.back": "Back to leaderboard", "footer.madeWith": "Made with", "footer.by": "by" } diff --git a/src/i18n/es.json b/src/i18n/es.json index 37e5a89..958da1d 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -89,6 +89,21 @@ "leaderboard.genreFilter": "Filtrar por genero", "leaderboard.allGenres": "Todos los generos", "leaderboard.fullLink": "Clasificacion completa", + "wrapped.title": "Wrapped", + "wrapped.subtitle.quarterly": "T{quarter} {year}", + "wrapped.subtitle.annual": "{year} en resumen", + "wrapped.overview": "Resumen", + "wrapped.songs": "canciones compartidas", + "wrapped.rounds": "rondas jugadas", + "wrapped.votes": "votos emitidos", + "wrapped.songOfPeriod": "Cancion del trimestre", + "wrapped.songOfYear": "Cancion del ano", + "wrapped.topMembers": "Top miembros", + "wrapped.topSongs": "Top 5 canciones", + "wrapped.genres": "Generos", + "wrapped.noData": "Aun no hay suficientes datos para este periodo", + "wrapped.selectPeriod": "Seleccionar periodo", + "wrapped.back": "Volver al ranking", "footer.madeWith": "Hecho con", "footer.by": "por" } From ac38cd3519811d2a22224029ea227e21a714b909 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 16:11:52 +0100 Subject: [PATCH 4/6] feat(wrapped): add WrappedView UI with animated cards Scroll-based cards with Intersection Observer fade-in animations. Includes period selector, overview stats, top song highlight, top members, top 5 songs, and genre distribution bar chart. 15 tests covering all states, locales, and interactions. --- src/features/wrapped/WrappedView.tsx | 488 ++++++++++++++++++ .../wrapped/__tests__/WrappedView.test.tsx | 305 +++++++++++ 2 files changed, 793 insertions(+) create mode 100644 src/features/wrapped/WrappedView.tsx create mode 100644 src/features/wrapped/__tests__/WrappedView.test.tsx diff --git a/src/features/wrapped/WrappedView.tsx b/src/features/wrapped/WrappedView.tsx new file mode 100644 index 0000000..7cfe068 --- /dev/null +++ b/src/features/wrapped/WrappedView.tsx @@ -0,0 +1,488 @@ +import { useEffect, useRef, useState } from 'react'; +import type { Locale } from '../../../site.config'; +import type { Group } from '../../lib/types'; +import { t } from '../../i18n'; +import { formatPeriodLabel, type WrappedPeriod, type WrappedStats } from '../../lib/wrapped'; +import { GenreBadge } from '../../components/ui/GenreBadge'; +import { Button } from '../../components/ui/Button'; +import { useWrapped } from './useWrapped'; + +interface WrappedViewProps { + group: Group; + locale: Locale; +} + +// --- Animation hook --- + +function useInView(): [React.RefObject, boolean] { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return [ref, visible]; +} + +function AnimatedCard({ + children, + delay = 0, + className = '', +}: { + children: React.ReactNode; + delay?: number; + className?: string; +}) { + const [ref, visible] = useInView(); + + return ( +
+ {children} +
+ ); +} + +// --- Sub-components --- + +function WrappedNav({ + groupName, + slug, + locale, +}: { + groupName: string; + slug: string; + locale: Locale; +}) { + const backUrl = locale === 'es' ? `/es/g/${slug}/leaderboard` : `/g/${slug}/leaderboard`; + + return ( + + ); +} + +function PeriodSelector({ + periods, + selected, + onSelect, +}: { + periods: WrappedPeriod[]; + selected: WrappedPeriod | null; + onSelect: (period: WrappedPeriod) => void; +}) { + return ( +
+ {periods.map((period) => { + const label = formatPeriodLabel(period); + const isActive = + selected !== null && + period.type === selected.type && + period.year === selected.year && + (period.type === 'annual' || + period.quarter === (selected as { quarter: number }).quarter); + + return ( + + ); + })} +
+ ); +} + +function OverviewCard({ stats, locale }: { stats: WrappedStats; locale: Locale }) { + const items = [ + { value: stats.totalSongs, label: t('wrapped.songs', locale) }, + { value: stats.totalRounds, label: t('wrapped.rounds', locale) }, + { value: stats.totalVotes, label: t('wrapped.votes', locale) }, + ]; + + return ( + +

+ {t('wrapped.overview', locale)} +

+
+ {items.map((item) => ( +
+

{item.value}

+

{item.label}

+
+ ))} +
+
+ ); +} + +function safeThumbUrl(url: string | null | undefined): string | null { + if (!url) return null; + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' ? url : null; + } catch { + return null; + } +} + +function TopSongCard({ stats, locale }: { stats: WrappedStats; locale: Locale }) { + if (!stats.topSong) return null; + + const song = stats.topSong; + const thumbUrl = safeThumbUrl(song.thumbnail_url); + const label = + stats.period.type === 'annual' + ? t('wrapped.songOfYear', locale) + : t('wrapped.songOfPeriod', locale); + + return ( + +

+ {label} +

+
+ {thumbUrl ? ( + + ) : ( +
+ +
+ )} +
+

{song.title}

+

{song.artist}

+
+
+ + + {song.avgRating.toFixed(1)} + +
+ {song.genre && } +
+
+
+

+ {t('ranking.by', locale)} {song.memberName} +

+
+ ); +} + +const MEDALS = ['\u{1F947}', '\u{1F948}', '\u{1F949}'] as const; +const MEDAL_COLORS = ['#FBBF24', '#C0C0C0', '#CD7F32'] as const; + +function TopMembersCard({ stats, locale }: { stats: WrappedStats; locale: Locale }) { + const members = stats.topMembers.filter((m) => m.songsAdded > 0); + if (members.length === 0) return null; + + return ( + +

+ {t('wrapped.topMembers', locale)} +

+
+ {members.map((member, i) => ( +
+ + {i < 3 ? ( + MEDALS[i] + ) : ( + {i + 1} + )} + +
+ {member.avatar} +
+
+

{member.name}

+

+ {member.songsAdded} {t('leaderboard.songs', locale)} ·{' '} + {t('leaderboard.avg', locale)} {member.avgReceived.toFixed(1)} ·{' '} + {member.roundsWon} + +

+
+ + {member.totalScore.toFixed(1)} + +
+ ))} +
+
+ ); +} + +function TopSongsCard({ stats, locale }: { stats: WrappedStats; locale: Locale }) { + if (stats.topSongs.length === 0) return null; + + return ( + +

+ {t('wrapped.topSongs', locale)} +

+
+ {stats.topSongs.map((song, i) => { + const thumbUrl = safeThumbUrl(song.thumbnail_url); + return ( +
+ + {i < 3 ? ( + MEDALS[i] + ) : ( + {i + 1} + )} + + {thumbUrl ? ( + + ) : ( +
+ +
+ )} +
+

{song.title}

+

+ {song.artist} · {t('ranking.by', locale)} {song.memberName} +

+
+ {song.totalVotes > 0 && ( +
+ + + {song.avgRating.toFixed(1)} + +
+ )} +
+ ); + })} +
+
+ ); +} + +function GenresCard({ stats, locale }: { stats: WrappedStats; locale: Locale }) { + if (stats.genreDistribution.length === 0) return null; + + const maxCount = stats.genreDistribution[0].count; + + return ( + +

+ {t('wrapped.genres', locale)} +

+
+ {stats.genreDistribution.map((item) => ( +
+
+ +
+
+
+
+
+ + {item.count} + +
+
+ ))} +
+ + ); +} + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function ErrorState({ + message, + onRetry, + locale, +}: { + message: string; + onRetry: () => void; + locale: Locale; +}) { + return ( +
+

{message}

+ +
+ ); +} + +function EmptyState({ locale }: { locale: Locale }) { + return ( +
+

{t('wrapped.noData', locale)}

+
+ ); +} + +// --- Main component --- + +export function WrappedView({ group, locale }: WrappedViewProps) { + const { stats, availablePeriods, selectedPeriod, setSelectedPeriod, isLoading, error } = + useWrapped(group.id); + + const hasData = stats !== null && stats.totalSongs > 0; + + return ( +
+ + + {isLoading ? ( + + ) : error ? ( + window.location.reload()} locale={locale} /> + ) : ( + <> + {availablePeriods.length > 0 && ( + + )} + + {!hasData ? ( + + ) : ( +
+ + + + + +
+ )} + + )} +
+ ); +} diff --git a/src/features/wrapped/__tests__/WrappedView.test.tsx b/src/features/wrapped/__tests__/WrappedView.test.tsx new file mode 100644 index 0000000..f6d6790 --- /dev/null +++ b/src/features/wrapped/__tests__/WrappedView.test.tsx @@ -0,0 +1,305 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { WrappedView } from '../WrappedView'; +import type { Group } from '../../../lib/types'; +import type { WrappedStats, WrappedPeriod } from '../../../lib/wrapped'; +import type { UseWrappedResult } from '../useWrapped'; + +// --- Mock useWrapped --- + +let mockResult: UseWrappedResult; + +vi.mock('../useWrapped', () => ({ + useWrapped: () => mockResult, +})); + +// --- Mock GenreBadge --- + +vi.mock('../../../components/ui/GenreBadge', () => ({ + GenreBadge: ({ genre }: { genre: string | null }) => + genre ? {genre} : null, +})); + +// --- Mock Button --- + +vi.mock('../../../components/ui/Button', () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})); + +// --- Mock IntersectionObserver --- + +class MockIntersectionObserver { + callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + // Trigger immediately so cards become visible in tests + setTimeout(() => { + this.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + }, 0); + } + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} + +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); +}); + +// --- Helpers --- + +const group: Group = { + id: 'group-1', + name: 'Test Group', + slug: 'test-group', + songs_per_round: 1, + created_at: '2026-01-01T00:00:00Z', +}; + +const defaultPeriod: WrappedPeriod = { type: 'quarterly', year: 2026, quarter: 1 }; + +function makeStats(overrides: Partial = {}): WrappedStats { + return { + period: defaultPeriod, + totalSongs: 10, + totalRounds: 3, + totalVotes: 25, + topSong: { + id: 'song-1', + title: 'Best Song', + artist: 'Best Artist', + thumbnail_url: null, + genre: 'rock', + memberName: 'Alice', + avgRating: 4.8, + totalVotes: 5, + }, + topMembers: [ + { + id: 'm1', + name: 'Alice', + avatar: '🎵', + totalScore: 15, + songsAdded: 3, + avgReceived: 4.5, + roundsWon: 2, + }, + { + id: 'm2', + name: 'Bob', + avatar: '🎸', + totalScore: 10, + songsAdded: 3, + avgReceived: 3.5, + roundsWon: 1, + }, + ], + topSongs: [ + { + id: 'song-1', + title: 'Best Song', + artist: 'Best Artist', + thumbnail_url: null, + genre: 'rock', + memberName: 'Alice', + avgRating: 4.8, + totalVotes: 5, + }, + { + id: 'song-2', + title: 'Second Song', + artist: 'Second Artist', + thumbnail_url: null, + genre: 'pop', + memberName: 'Bob', + avgRating: 4.2, + totalVotes: 4, + }, + ], + genreDistribution: [ + { genre: 'rock', count: 5 }, + { genre: 'pop', count: 3 }, + ], + topGenre: 'rock', + ...overrides, + }; +} + +function resetMockResult() { + mockResult = { + stats: makeStats(), + availablePeriods: [ + defaultPeriod, + { type: 'quarterly', year: 2025, quarter: 4 }, + { type: 'annual', year: 2025 }, + ], + selectedPeriod: defaultPeriod, + setSelectedPeriod: vi.fn(), + isLoading: false, + error: null, + }; +} + +describe('WrappedView', () => { + beforeEach(() => { + resetMockResult(); + }); + + it('renders loading spinner when loading', () => { + mockResult = { ...mockResult, isLoading: true, stats: null }; + const { container } = render(); + expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('renders error state with message', () => { + mockResult = { ...mockResult, error: 'Network error', stats: null }; + render(); + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + + it('renders overview stats (songs, rounds, votes)', () => { + render(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('25')).toBeInTheDocument(); + expect(screen.getByText('songs shared')).toBeInTheDocument(); + expect(screen.getByText('rounds played')).toBeInTheDocument(); + expect(screen.getByText('votes cast')).toBeInTheDocument(); + }); + + it('renders top song card with title and artist', () => { + render(); + // "Best Song" appears in both topSong card and topSongs list + expect(screen.getAllByText('Best Song').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Best Artist').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Song of the quarter')).toBeInTheDocument(); + }); + + it('renders top members', () => { + render(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('15.0')).toBeInTheDocument(); + }); + + it('renders genre distribution', () => { + render(); + const badges = screen.getAllByTestId('genre-badge'); + expect(badges).toHaveLength(3); // 1 in top song + 2 in genre card + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('renders empty state when no data', () => { + mockResult = { + ...mockResult, + stats: makeStats({ totalSongs: 0, totalRounds: 0, totalVotes: 0 }), + }; + render(); + expect(screen.getByText('Not enough data for this period yet')).toBeInTheDocument(); + }); + + it('renders period selector with available periods', () => { + render(); + expect(screen.getByText('Q1 2026')).toBeInTheDocument(); + expect(screen.getByText('Q4 2025')).toBeInTheDocument(); + expect(screen.getByText('2025')).toBeInTheDocument(); + }); + + it('calls setSelectedPeriod when a period chip is clicked', () => { + render(); + fireEvent.click(screen.getByText('Q4 2025')); + expect(mockResult.setSelectedPeriod).toHaveBeenCalledWith({ + type: 'quarterly', + year: 2025, + quarter: 4, + }); + }); + + it('renders nav with group name and wrapped title', () => { + render(); + expect(screen.getByText('Wrapped')).toBeInTheDocument(); + expect(screen.getByText('Test Group')).toBeInTheDocument(); + }); + + it('renders back link to leaderboard', () => { + render(); + const link = screen.getByLabelText('Back to leaderboard'); + expect(link).toHaveAttribute('href', '/g/test-group/leaderboard'); + }); + + it('renders Spanish locale with correct back URL and labels', () => { + render(); + const link = screen.getByLabelText('Volver al ranking'); + expect(link).toHaveAttribute('href', '/es/g/test-group/leaderboard'); + expect(screen.getByText('canciones compartidas')).toBeInTheDocument(); + expect(screen.getByText('rondas jugadas')).toBeInTheDocument(); + }); + + it('renders song of the year label for annual period', () => { + mockResult = { + ...mockResult, + selectedPeriod: { type: 'annual', year: 2025 }, + stats: makeStats({ period: { type: 'annual', year: 2025 } }), + }; + render(); + expect(screen.getByText('Song of the year')).toBeInTheDocument(); + }); + + it('renders thumbnail image when valid HTTPS URL provided', () => { + mockResult = { + ...mockResult, + stats: makeStats({ + topSong: { + id: 'song-1', + title: 'Thumb Song', + artist: 'Thumb Artist', + thumbnail_url: 'https://example.com/thumb.jpg', + genre: null, + memberName: 'Alice', + avgRating: 4.5, + totalVotes: 3, + }, + }), + }; + render(); + const imgs = document.querySelectorAll('img[src="https://example.com/thumb.jpg"]'); + expect(imgs.length).toBeGreaterThanOrEqual(1); + }); + + it('excludes members with zero songsAdded from top members', () => { + mockResult = { + ...mockResult, + stats: makeStats({ + topMembers: [ + { + id: 'm1', + name: 'Active', + avatar: '🎵', + totalScore: 10, + songsAdded: 2, + avgReceived: 4.0, + roundsWon: 1, + }, + { + id: 'm2', + name: 'Inactive', + avatar: '🔇', + totalScore: 0, + songsAdded: 0, + avgReceived: 0, + roundsWon: 0, + }, + ], + }), + }; + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.queryByText('Inactive')).not.toBeInTheDocument(); + }); +}); From ee0d56bf93b91492d40ddf8d569f7b92b1352f6c Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 16:12:00 +0100 Subject: [PATCH 5/6] feat(wrapped): add Astro pages for EN and ES routes Add /g/[slug]/wrapped and /es/g/[slug]/wrapped routes following the same pattern as leaderboard pages with server-side group lookup. --- src/pages/es/g/[slug]/wrapped.astro | 35 ++++++++++++++++++++++++++ src/pages/g/[slug]/wrapped.astro | 38 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/pages/es/g/[slug]/wrapped.astro create mode 100644 src/pages/g/[slug]/wrapped.astro diff --git a/src/pages/es/g/[slug]/wrapped.astro b/src/pages/es/g/[slug]/wrapped.astro new file mode 100644 index 0000000..851e2b5 --- /dev/null +++ b/src/pages/es/g/[slug]/wrapped.astro @@ -0,0 +1,35 @@ +--- +import BaseLayout from '../../../../layouts/BaseLayout.astro'; +import Navbar from '../../../../components/Navbar.astro'; +import Footer from '../../../../components/Footer.astro'; +import { WrappedView } from '../../../../features/wrapped/WrappedView'; +import { supabase } from '../../../../lib/supabase'; +import { t } from '../../../../i18n'; +import type { Group } from '../../../../lib/types'; + +const { slug } = Astro.params; + +if (!slug) { + return Astro.redirect('/es'); +} + +const { data: group, error: groupError } = await supabase + .from('groups') + .select('*') + .eq('slug', slug) + .maybeSingle(); + +if (groupError || !group) { + return Astro.redirect('/404'); +} + +const typedGroup = group as Group; +--- + + + +
+ +
+
+ diff --git a/src/pages/g/[slug]/wrapped.astro b/src/pages/g/[slug]/wrapped.astro new file mode 100644 index 0000000..83fc473 --- /dev/null +++ b/src/pages/g/[slug]/wrapped.astro @@ -0,0 +1,38 @@ +--- +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import Navbar from '../../../components/Navbar.astro'; +import Footer from '../../../components/Footer.astro'; +import { WrappedView } from '../../../features/wrapped/WrappedView'; +import { supabase } from '../../../lib/supabase'; +import { t } from '../../../i18n'; +import type { Group } from '../../../lib/types'; + +const { slug } = Astro.params; + +if (!slug) { + return Astro.redirect('/'); +} + +const { data: group, error: groupError } = await supabase + .from('groups') + .select('*') + .eq('slug', slug) + .maybeSingle(); + +if (groupError || !group) { + return Astro.redirect('/404'); +} + +const typedGroup = group as Group; + +const acceptLang = Astro.request.headers.get('accept-language') ?? ''; +const locale = acceptLang.startsWith('es') ? 'es' : 'en'; +--- + + + +
+ +
+
+ From b22021ca321e2619231f027062d67f46b9bdea29 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 16:12:07 +0100 Subject: [PATCH 6/6] feat(wrapped): add wrapped link to leaderboard nav bar Add accent-colored Wrapped button next to the group name in the leaderboard navigation, linking to the wrapped page for the group. --- src/features/leaderboard/LeaderboardView.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/features/leaderboard/LeaderboardView.tsx b/src/features/leaderboard/LeaderboardView.tsx index bfb9d7d..3a5a708 100644 --- a/src/features/leaderboard/LeaderboardView.tsx +++ b/src/features/leaderboard/LeaderboardView.tsx @@ -29,6 +29,7 @@ function LeaderboardNav({ locale: Locale; }) { const backUrl = locale === 'es' ? `/es/g/${slug}` : `/g/${slug}`; + const wrappedUrl = locale === 'es' ? `/es/g/${slug}/wrapped` : `/g/${slug}/wrapped`; return (
- {groupName} + ); }