diff --git a/.env.example b/.env.example index 1162020..5c87b07 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ PUBLIC_SUPABASE_URL=https://your-project.supabase.co PUBLIC_SUPABASE_ANON_KEY=your-anon-key +LASTFM_API_KEY=your-lastfm-api-key diff --git a/eslint.config.js b/eslint.config.js index 43af235..5fa9849 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,7 +28,7 @@ export default [ }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - 'no-console': 'warn', + 'no-console': ['warn', { allow: ['warn', 'error'] }], }, }, ]; diff --git a/package.json b/package.json index 0e15b35..ccb88a7 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@capacitor/cli": "^8.1.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "eslint": "^10.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4047757..84a0f7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@typescript-eslint/eslint-plugin': specifier: ^8.56.1 version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) @@ -1788,6 +1791,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@trapezedev/gradle-parse@7.1.3': resolution: {integrity: sha512-WQVF5pEJ5o/mUyvfGTG9nBKx9Te/ilKM3r2IT69GlbaooItT5ao7RyF1MUTBNjHLPk/xpGUY3c6PyVnjDlz0Vw==} @@ -8175,6 +8184,10 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@trapezedev/gradle-parse@7.1.3': {} '@trapezedev/project@7.1.3(@types/node@25.3.0)(typescript@5.9.3)': diff --git a/src/components/ui/GenreBadge.tsx b/src/components/ui/GenreBadge.tsx new file mode 100644 index 0000000..ea017d5 --- /dev/null +++ b/src/components/ui/GenreBadge.tsx @@ -0,0 +1,26 @@ +import { GENRES, getGenreColor, getGenreLabel, type Genre } from '../../lib/genres'; + +interface GenreBadgeProps { + genre: string | null; +} + +const genreSet = new Set(GENRES); + +function isGenre(value: string): value is Genre { + return genreSet.has(value); +} + +/** Colored badge/chip for a music genre. Renders nothing if genre is null. */ +export function GenreBadge({ genre }: GenreBadgeProps) { + if (!genre) return null; + + const known = isGenre(genre); + const colorClasses = known ? getGenreColor(genre) : getGenreColor('other'); + const label = known ? getGenreLabel(genre) : genre; + + return ( + + {label} + + ); +} diff --git a/src/components/ui/GenreFilter.tsx b/src/components/ui/GenreFilter.tsx new file mode 100644 index 0000000..cdc9595 --- /dev/null +++ b/src/components/ui/GenreFilter.tsx @@ -0,0 +1,27 @@ +import type { Locale } from '../../../site.config'; +import { GENRES, getGenreLabel } from '../../lib/genres'; +import { t } from '../../i18n'; + +interface GenreFilterProps { + value: string | null; + onChange: (genre: string | null) => void; + locale: Locale; +} + +export function GenreFilter({ value, onChange, locale }: GenreFilterProps) { + return ( + + ); +} diff --git a/src/components/ui/__tests__/GenreBadge.test.tsx b/src/components/ui/__tests__/GenreBadge.test.tsx new file mode 100644 index 0000000..d2c650b --- /dev/null +++ b/src/components/ui/__tests__/GenreBadge.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { GenreBadge } from '../GenreBadge'; + +describe('GenreBadge', () => { + it('should render the genre label', () => { + render(); + expect(screen.getByText('Rock')).toBeInTheDocument(); + }); + + it('should apply genre-specific color classes', () => { + const { container } = render(); + const badge = container.firstElementChild!; + expect(badge.className).toContain('bg-red-500/15'); + expect(badge.className).toContain('text-red-400'); + }); + + it('should render nothing when genre is null', () => { + const { container } = render(); + expect(container.firstElementChild).toBeNull(); + }); + + it('should apply fallback color for unknown genre', () => { + const { container } = render(); + const badge = container.firstElementChild!; + expect(badge.className).toContain('bg-neutral-500/15'); + expect(badge.className).toContain('text-neutral-400'); + }); + + it('should render unknown genre string as-is for label', () => { + render(); + expect(screen.getByText('unknown-genre')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/__tests__/GenreFilter.test.tsx b/src/components/ui/__tests__/GenreFilter.test.tsx new file mode 100644 index 0000000..85d685e --- /dev/null +++ b/src/components/ui/__tests__/GenreFilter.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; +import { GenreFilter } from '../GenreFilter'; +import { GENRES } from '../../../lib/genres'; + +describe('GenreFilter', () => { + it('should render a select with "All genres" as default option', () => { + render(); + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + expect((select as HTMLSelectElement).value).toBe(''); + }); + + it('should list all genres as options plus the "All" option', () => { + render(); + const options = screen.getAllByRole('option'); + expect(options.length).toBe(GENRES.length + 1); // genres + "All genres" + }); + + it('should call onChange with genre when selected', async () => { + const onChange = vi.fn(); + render(); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'rock'); + expect(onChange).toHaveBeenCalledWith('rock'); + }); + + it('should call onChange with null when "All" is selected', async () => { + const onChange = vi.fn(); + render(); + + await userEvent.selectOptions(screen.getByRole('combobox'), ''); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('should have accessible label', () => { + render(); + expect(screen.getByLabelText('Filter by genre')).toBeInTheDocument(); + }); + + it('should show Spanish labels when locale is es', () => { + render(); + expect(screen.getByLabelText('Filtrar por genero')).toBeInTheDocument(); + expect(screen.getByText('Todos los generos')).toBeDefined(); + }); + + it('should reflect the current value', () => { + render(); + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('jazz'); + }); +}); diff --git a/src/features/group/AddSong.tsx b/src/features/group/AddSong.tsx index af6aa0d..c11ff6e 100644 --- a/src/features/group/AddSong.tsx +++ b/src/features/group/AddSong.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; import type { Locale } from '../../../site.config'; import { t } from '../../i18n'; +import type { TranslationKey } from '../../i18n'; +import { GENRES, getGenreLabel } from '../../lib/genres'; import { Button } from '../../components/ui/Button'; interface AddSongProps { - onAddSong: (url: string) => Promise; + onAddSong: (url: string, genre: string | null) => Promise; locale: Locale; } @@ -16,6 +18,7 @@ type AddSongState = 'idle' | 'resolving' | 'error'; */ export function AddSong({ onAddSong, locale }: AddSongProps) { const [url, setUrl] = useState(''); + const [genre, setGenre] = useState(''); const [state, setState] = useState('idle'); const [errorMessage, setErrorMessage] = useState(''); @@ -28,9 +31,10 @@ export function AddSong({ onAddSong, locale }: AddSongProps) { setErrorMessage(''); try { - await onAddSong(trimmed); - // Success: clear the input + await onAddSong(trimmed, genre || null); + // Success: clear the inputs setUrl(''); + setGenre(''); setState('idle'); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; @@ -41,7 +45,7 @@ export function AddSong({ onAddSong, locale }: AddSongProps) { return (
-
+
@@ -60,11 +64,29 @@ export function AddSong({ onAddSong, locale }: AddSongProps) { disabled={state === 'resolving'} className="min-w-0 flex-1 rounded-lg border border-border bg-bg-input px-3.5 py-2.5 text-sm text-text placeholder:text-text-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50" /> - +
+ + +
{state === 'error' && errorMessage && ( diff --git a/src/features/group/SongCard.tsx b/src/features/group/SongCard.tsx index 945e5e4..a200010 100644 --- a/src/features/group/SongCard.tsx +++ b/src/features/group/SongCard.tsx @@ -3,6 +3,7 @@ import type { Member, SongWithVotes } from '../../lib/types'; import { t } from '../../i18n'; import { StarRating } from '../../components/ui/StarRating'; import { PlatformLinks } from '../../components/ui/PlatformLinks'; +import { GenreBadge } from '../../components/ui/GenreBadge'; interface SongCardProps { song: SongWithVotes; @@ -72,6 +73,12 @@ export function SongCard({ song, memberId, members, onRate, locale }: SongCardPr

{song.title}

{song.artist}

+ {song.genre && ( +
+ +
+ )} + {/* Added by */}

{t('group.songCard.addedBy', locale)}{' '} diff --git a/src/features/group/__tests__/AddSong.test.tsx b/src/features/group/__tests__/AddSong.test.tsx index dc30aad..6556095 100644 --- a/src/features/group/__tests__/AddSong.test.tsx +++ b/src/features/group/__tests__/AddSong.test.tsx @@ -4,10 +4,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { AddSong } from '../AddSong'; describe('AddSong', () => { - let mockOnAddSong: ReturnType Promise>>; + let mockOnAddSong: ReturnType Promise>>; beforeEach(() => { - mockOnAddSong = vi.fn<(url: string) => Promise>(); + mockOnAddSong = vi.fn<(url: string, genre: string | null) => Promise>(); }); it('should render input and submit button', () => { @@ -34,7 +34,7 @@ describe('AddSong', () => { expect(button.hasAttribute('disabled')).toBe(false); }); - it('should call onAddSong with trimmed URL on submit', async () => { + it('should call onAddSong with trimmed URL and null genre on submit', async () => { mockOnAddSong.mockResolvedValue(undefined); render(); @@ -45,7 +45,7 @@ describe('AddSong', () => { fireEvent.submit(form); await waitFor(() => { - expect(mockOnAddSong).toHaveBeenCalledWith('https://open.spotify.com/track/abc'); + expect(mockOnAddSong).toHaveBeenCalledWith('https://open.spotify.com/track/abc', null); }); }); @@ -162,4 +162,47 @@ describe('AddSong', () => { expect(mockOnAddSong).not.toHaveBeenCalled(); }); + + it('should render genre select dropdown', () => { + render(); + + const select = screen.getByRole('combobox', { name: 'Genre' }); + expect(select).toBeDefined(); + }); + + it('should call onAddSong with selected genre on submit', async () => { + mockOnAddSong.mockResolvedValue(undefined); + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); + + const select = screen.getByRole('combobox', { name: 'Genre' }); + fireEvent.change(select, { target: { value: 'rock' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(mockOnAddSong).toHaveBeenCalledWith('https://open.spotify.com/track/abc', 'rock'); + }); + }); + + it('should reset genre select after successful submission', async () => { + mockOnAddSong.mockResolvedValue(undefined); + render(); + + const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); + fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); + + const select = screen.getByRole('combobox', { name: 'Genre' }) as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'rock' } }); + + const form = input.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(select.value).toBe(''); + }); + }); }); diff --git a/src/features/group/__tests__/SongCard.test.tsx b/src/features/group/__tests__/SongCard.test.tsx index 66d8e57..9f0d165 100644 --- a/src/features/group/__tests__/SongCard.test.tsx +++ b/src/features/group/__tests__/SongCard.test.tsx @@ -17,6 +17,7 @@ function makeSong(overrides: Partial = {}): SongWithVotes { thumbnail_url: 'https://i.scdn.co/image/abc', platform_links: [{ platform: 'spotify', url: 'https://open.spotify.com/track/abc' }], odesli_page_url: 'https://song.link/s/abc', + genre: null, created_at: '2026-01-01T00:00:00Z', votes: [], avgRating: 0, @@ -170,4 +171,17 @@ describe('SongCard', () => { const img = screen.getByRole('img'); expect(img.getAttribute('alt')).toBe('Bohemian Rhapsody cover'); }); + + it('should display genre badge when song has a genre', () => { + const song = makeSong({ genre: 'rock' }); + render(); + expect(screen.getByText('Rock')).toBeDefined(); + }); + + it('should not display genre badge when song genre is null', () => { + const song = makeSong({ genre: null }); + render(); + expect(screen.queryByText('Rock')).toBeNull(); + expect(screen.queryByText('Pop')).toBeNull(); + }); }); diff --git a/src/features/group/useGroup.ts b/src/features/group/useGroup.ts index f6dd2f9..e2638cf 100644 --- a/src/features/group/useGroup.ts +++ b/src/features/group/useGroup.ts @@ -2,15 +2,23 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { supabase } from '../../lib/supabase'; import { ensureCurrentRound } from '../../lib/rounds'; import { resolveSongLink } from '../../lib/odesli'; +import { GENRES } from '../../lib/genres'; import type { Member, Round, Song, Vote, SongWithVotes } from '../../lib/types'; +const VALID_GENRES = new Set(GENRES); + +function validateGenre(genre: string | null): string | null { + if (!genre) return null; + return VALID_GENRES.has(genre) ? genre : null; +} + interface UseGroupResult { round: Round | null; songs: SongWithVotes[]; members: Member[]; isLoading: boolean; error: string | null; - addSong: (url: string) => Promise; + addSong: (url: string, genre?: string | null) => Promise; voteSong: (songId: string, rating: number) => Promise; refetch: () => Promise; } @@ -151,7 +159,7 @@ export function useGroup( /** Add a new song by resolving the URL via Odesli and inserting into Supabase */ const addSong = useCallback( - async (url: string) => { + async (url: string, genre: string | null = null) => { if (!round || !memberId) { throw new Error('Cannot add song: no active round or member'); } @@ -179,6 +187,7 @@ export function useGroup( thumbnail_url: resolved.thumbnailUrl, platform_links: resolved.platformLinks, odesli_page_url: resolved.pageUrl, + genre: validateGenre(genre ?? resolved.genre), }) .select() .single(); diff --git a/src/features/leaderboard/LeaderboardView.tsx b/src/features/leaderboard/LeaderboardView.tsx index ae57774..bfb9d7d 100644 --- a/src/features/leaderboard/LeaderboardView.tsx +++ b/src/features/leaderboard/LeaderboardView.tsx @@ -3,6 +3,7 @@ import type { Locale } from '../../../site.config'; import type { Group, MemberStats } from '../../lib/types'; import { t } from '../../i18n'; import { Button } from '../../components/ui/Button'; +import { GenreFilter } from '../../components/ui/GenreFilter'; import { useLeaderboard } from './useLeaderboard'; import type { PastRoundSong } from './useLeaderboard'; import { Podium } from './Podium'; @@ -287,9 +288,14 @@ function ErrorState({ */ export function LeaderboardView({ group, locale }: LeaderboardViewProps) { const [activeTab, setActiveTab] = useState('current'); + const [genreFilter, setGenreFilter] = useState(null); const { members, pastRounds, topSongs, currentRoundNumber, currentRoundSongs, isLoading, error } = useLeaderboard(group.id); + const filteredPastRounds = genreFilter + ? pastRounds.filter((r) => r.topGenre === genreFilter) + : pastRounds; + return (

@@ -313,7 +319,10 @@ export function LeaderboardView({ group, locale }: LeaderboardViewProps) { - +
+ +
+ )} diff --git a/src/features/leaderboard/PastRounds.tsx b/src/features/leaderboard/PastRounds.tsx index 7bf28a8..1e309f1 100644 --- a/src/features/leaderboard/PastRounds.tsx +++ b/src/features/leaderboard/PastRounds.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import type { Locale } from '../../../site.config'; import type { PastRound, PastRoundSong } from './useLeaderboard'; import { t } from '../../i18n'; +import { GenreBadge } from '../../components/ui/GenreBadge'; interface PastRoundsProps { rounds: PastRound[]; @@ -143,6 +144,11 @@ export function PastRounds({ rounds, locale }: PastRoundsProps) {

{round.topSong} — {round.topArtist}

+ {round.topGenre && ( +
+ +
+ )}
{/* Right: winner + score + chevron */} diff --git a/src/features/leaderboard/__tests__/PastRounds.test.tsx b/src/features/leaderboard/__tests__/PastRounds.test.tsx new file mode 100644 index 0000000..bb37701 --- /dev/null +++ b/src/features/leaderboard/__tests__/PastRounds.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { PastRounds } from '../PastRounds'; +import type { PastRound } from '../useLeaderboard'; + +const mockRounds: PastRound[] = [ + { + number: 1, + winner: 'Alice', + topSong: 'Bohemian Rhapsody', + topArtist: 'Queen', + topGenre: 'rock', + topScore: 4.5, + songs: [], + }, + { + number: 2, + winner: 'Bob', + topSong: 'So What', + topArtist: 'Miles Davis', + topGenre: 'jazz', + topScore: 3.8, + songs: [], + }, + { + number: 3, + winner: 'Carol', + topSong: 'No Genre Song', + topArtist: 'Unknown', + topGenre: null, + topScore: 4.0, + songs: [], + }, +]; + +describe('PastRounds', () => { + it('renders empty state when no rounds', () => { + render(); + expect(screen.getByText('No past rounds yet')).toBeInTheDocument(); + }); + + it('renders round numbers and song info', () => { + render(); + expect(screen.getByText('Bohemian Rhapsody — Queen')).toBeInTheDocument(); + expect(screen.getByText('So What — Miles Davis')).toBeInTheDocument(); + expect(screen.getByText('No Genre Song — Unknown')).toBeInTheDocument(); + }); + + it('shows GenreBadge for rounds with a genre', () => { + render(); + expect(screen.getByText('Rock')).toBeInTheDocument(); + expect(screen.getByText('Jazz')).toBeInTheDocument(); + }); + + it('does not show GenreBadge for rounds with null genre', () => { + render(); + expect(screen.queryByText('Rock')).not.toBeInTheDocument(); + expect(screen.queryByText('Jazz')).not.toBeInTheDocument(); + }); + + it('shows winner names', () => { + render(); + expect(screen.getByText(/Alice/)).toBeInTheDocument(); + expect(screen.getByText(/Bob/)).toBeInTheDocument(); + expect(screen.getByText(/Carol/)).toBeInTheDocument(); + }); + + it('displays scores formatted to one decimal', () => { + render(); + expect(screen.getByText('4.5')).toBeInTheDocument(); + expect(screen.getByText('3.8')).toBeInTheDocument(); + expect(screen.getByText('4.0')).toBeInTheDocument(); + }); + + it('renders in Spanish locale', () => { + render(); + expect(screen.getByText('Aun no hay rondas pasadas')).toBeInTheDocument(); + }); +}); diff --git a/src/features/leaderboard/__tests__/useLeaderboard.test.ts b/src/features/leaderboard/__tests__/useLeaderboard.test.ts index be5793b..67a369d 100644 --- a/src/features/leaderboard/__tests__/useLeaderboard.test.ts +++ b/src/features/leaderboard/__tests__/useLeaderboard.test.ts @@ -92,6 +92,7 @@ function makeSong(overrides: Partial = {}): Song { thumbnail_url: null, platform_links: [], odesli_page_url: null, + genre: null, created_at: '2026-01-01T00:00:00Z', ...overrides, }; @@ -324,6 +325,7 @@ describe('useLeaderboard', () => { winner: 'Alice', topSong: 'Bohemian Rhapsody', topArtist: 'Queen', + topGenre: null, topScore: 4.5, songs: [ { diff --git a/src/features/leaderboard/useLeaderboard.ts b/src/features/leaderboard/useLeaderboard.ts index f880f27..fcbff83 100644 --- a/src/features/leaderboard/useLeaderboard.ts +++ b/src/features/leaderboard/useLeaderboard.ts @@ -17,6 +17,7 @@ export interface PastRound { winner: string; topSong: string; topArtist: string; + topGenre: string | null; topScore: number; songs: PastRoundSong[]; } @@ -230,6 +231,7 @@ export function useLeaderboard(groupId: string): UseLeaderboardResult { winner: winnerMember?.name ?? '—', topSong: winner?.topSong.title ?? roundSongData[0]?.title ?? '—', topArtist: winner?.topSong.artist ?? roundSongData[0]?.artist ?? '—', + topGenre: winner?.topSong.genre ?? null, topScore: winner?.topScore ?? 0, songs: roundSongData, }); diff --git a/src/i18n/en.json b/src/i18n/en.json index 0b1739c..6f8379f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -58,6 +58,8 @@ "group.addSong.placeholder": "Paste a Spotify or YouTube link...", "group.addSong.button": "Add", "group.addSong.resolving": "Resolving...", + "group.addSong.genreLabel": "Genre", + "group.addSong.genrePlaceholder": "Genre (optional)", "group.addSong.error": "Could not resolve that link. Try another.", "group.songCard.addedBy": "Added by", "group.songCard.yourTrack": "Your track", @@ -84,6 +86,8 @@ "leaderboard.avg": "avg", "leaderboard.wins": "wins", "leaderboard.pts": "pts", + "leaderboard.genreFilter": "Filter by genre", + "leaderboard.allGenres": "All genres", "leaderboard.fullLink": "Full leaderboard", "footer.madeWith": "Made with", "footer.by": "by" diff --git a/src/i18n/es.json b/src/i18n/es.json index 35c0c56..37e5a89 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -58,6 +58,8 @@ "group.addSong.placeholder": "Pega un enlace de Spotify o YouTube...", "group.addSong.button": "Anadir", "group.addSong.resolving": "Resolviendo...", + "group.addSong.genreLabel": "Género", + "group.addSong.genrePlaceholder": "Género (opcional)", "group.addSong.error": "No se pudo resolver ese enlace. Prueba con otro.", "group.songCard.addedBy": "Anadida por", "group.songCard.yourTrack": "Tu cancion", @@ -84,6 +86,8 @@ "leaderboard.avg": "media", "leaderboard.wins": "victorias", "leaderboard.pts": "pts", + "leaderboard.genreFilter": "Filtrar por genero", + "leaderboard.allGenres": "Todos los generos", "leaderboard.fullLink": "Clasificacion completa", "footer.madeWith": "Hecho con", "footer.by": "por" diff --git a/src/lib/__tests__/genres.test.ts b/src/lib/__tests__/genres.test.ts new file mode 100644 index 0000000..dff81ab --- /dev/null +++ b/src/lib/__tests__/genres.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { GENRES, getGenreLabel, getGenreColor, matchGenre } from '../genres'; + +describe('genres', () => { + it('GENRES is a non-empty array of strings', () => { + expect(Array.isArray(GENRES)).toBe(true); + expect(GENRES.length).toBeGreaterThan(0); + GENRES.forEach((genre) => { + expect(typeof genre).toBe('string'); + }); + }); + + it('getGenreLabel returns a label for every genre', () => { + GENRES.forEach((genre) => { + const label = getGenreLabel(genre); + expect(typeof label).toBe('string'); + expect(label.length).toBeGreaterThan(0); + }); + }); + + it('getGenreColor returns Tailwind classes for every genre', () => { + GENRES.forEach((genre) => { + const color = getGenreColor(genre); + expect(color).toContain('bg-'); + expect(color).toContain('text-'); + }); + }); + + it('getGenreColor returns fallback for unknown genre', () => { + const color = getGenreColor('nonexistent' as never); + expect(color).toContain('bg-'); + expect(color).toContain('text-'); + }); + + it('returns correct labels for specific genres', () => { + expect(getGenreLabel('rock')).toBe('Rock'); + expect(getGenreLabel('hip-hop')).toBe('Hip-Hop'); + expect(getGenreLabel('r&b')).toBe('R&B'); + }); +}); + +describe('matchGenre', () => { + it('matches exact genre slug', () => { + expect(matchGenre(['rock'])).toBe('rock'); + }); + + it('matches via alias (hip hop -> hip-hop)', () => { + expect(matchGenre(['hip hop'])).toBe('hip-hop'); + }); + + it('returns first matching genre by tag priority', () => { + expect(matchGenre(['indie rock', 'rock', 'alternative'])).toBe('indie'); + }); + + it('returns null when no tags match', () => { + expect(matchGenre(['seen live', 'favorites', 'my playlist'])).toBeNull(); + }); + + it('returns null for empty array', () => { + expect(matchGenre([])).toBeNull(); + }); + + it('is case-insensitive', () => { + expect(matchGenre(['ROCK'])).toBe('rock'); + expect(matchGenre(['Hip-Hop'])).toBe('hip-hop'); + }); + + it('matches r&b variants', () => { + expect(matchGenre(['rnb'])).toBe('r&b'); + expect(matchGenre(['rhythm and blues'])).toBe('r&b'); + }); + + it('matches electronic variants', () => { + expect(matchGenre(['edm'])).toBe('electronic'); + expect(matchGenre(['drum and bass'])).toBe('electronic'); + expect(matchGenre(['techno'])).toBe('electronic'); + }); + + it('does not false-positive "popular" as "pop"', () => { + expect(matchGenre(['popular'])).toBeNull(); + }); + + it('does not false-positive "countryside" as "country"', () => { + expect(matchGenre(['countryside'])).toBeNull(); + }); + + it('does not false-positive "funky" as "funk"', () => { + expect(matchGenre(['funky'])).toBeNull(); + }); + + it('does not false-positive "folktronica" as "folk"', () => { + expect(matchGenre(['folktronica'])).toBeNull(); + }); + + it('matches new explicit aliases', () => { + expect(matchGenre(['k-pop'])).toBe('pop'); + expect(matchGenre(['metalcore'])).toBe('metal'); + expect(matchGenre(['shoegaze'])).toBe('alternative'); + expect(matchGenre(['bluegrass'])).toBe('country'); + expect(matchGenre(['disco'])).toBe('funk'); + expect(matchGenre(['ska'])).toBe('reggae'); + }); +}); diff --git a/src/lib/__tests__/lastfm.test.ts b/src/lib/__tests__/lastfm.test.ts new file mode 100644 index 0000000..7848a10 --- /dev/null +++ b/src/lib/__tests__/lastfm.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { detectGenre } from '../lastfm'; + +function makeTagResponse(tags: { name: string; count: number }[]) { + return { + ok: true, + status: 200, + json: () => Promise.resolve({ toptags: { tag: tags } }), + } as unknown as Response; +} + +function makeErrorResponse(status: number): Response { + return { + ok: false, + status, + json: () => Promise.resolve({}), + } as unknown as Response; +} + +describe('detectGenre', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + vi.stubEnv('LASTFM_API_KEY', 'test-key'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should return genre from track tags on first call', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'rock', count: 100 }, + { name: 'classic rock', count: 80 }, + ]), + ); + + const result = await detectGenre('Bohemian Rhapsody', 'Queen'); + + expect(result).toBe('rock'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + it('should fallback to artist tags when track tags do not match', async () => { + const mockFetch = vi.mocked(fetch); + // Track tags: no genre match + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'seen live', count: 100 }, + { name: 'favorites', count: 90 }, + ]), + ); + // Artist tags: genre match + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'electronic', count: 100 }, + { name: 'ambient', count: 80 }, + ]), + ); + + const result = await detectGenre('Unknown Track', 'Boards of Canada'); + + expect(result).toBe('electronic'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should return null when neither track nor artist tags match', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'seen live', count: 100 }, + { name: 'favorites', count: 90 }, + ]), + ); + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'seen live', count: 100 }, + { name: 'british', count: 80 }, + ]), + ); + + const result = await detectGenre('Some Track', 'Some Artist'); + + expect(result).toBeNull(); + }); + + it('should return null when track request fails and artist tags do not match', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeErrorResponse(500)); + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'seen live', count: 100 }, + { name: 'favorites', count: 80 }, + ]), + ); + + const result = await detectGenre('Track', 'Artist'); + + expect(result).toBeNull(); + }); + + it('should return null when both requests fail with network error', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')); + mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')); + + const result = await detectGenre('Track', 'Artist'); + + expect(result).toBeNull(); + }); + + it('should pass track and artist as query parameters with autocorrect=1', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeTagResponse([{ name: 'rock', count: 100 }])); + + await detectGenre('Stairway to Heaven', 'Led Zeppelin'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const calledUrl = mockFetch.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get('method')).toBe('track.getTopTags'); + expect(url.searchParams.get('track')).toBe('Stairway to Heaven'); + expect(url.searchParams.get('artist')).toBe('Led Zeppelin'); + expect(url.searchParams.get('autocorrect')).toBe('1'); + expect(url.searchParams.get('api_key')).toBe('test-key'); + expect(url.searchParams.get('format')).toBe('json'); + }); + + it('should handle empty tag arrays gracefully', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeTagResponse([])); + mockFetch.mockResolvedValueOnce(makeTagResponse([])); + + const result = await detectGenre('Track', 'Artist'); + + expect(result).toBeNull(); + }); + + it.each(['Hip-Hop', 'hip hop', 'rap', 'Rap', 'Hip Hop'])( + 'should match "%s" as hip-hop', + async (variant) => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeTagResponse([{ name: variant, count: 100 }])); + + const result = await detectGenre('Track', 'Artist'); + expect(result).toBe('hip-hop'); + }, + ); + + it('should return null when API key is not set', async () => { + vi.stubEnv('LASTFM_API_KEY', ''); + + const result = await detectGenre('Track', 'Artist'); + + expect(result).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/__tests__/odesli.test.ts b/src/lib/__tests__/odesli.test.ts index f5425cb..317974b 100644 --- a/src/lib/__tests__/odesli.test.ts +++ b/src/lib/__tests__/odesli.test.ts @@ -8,12 +8,14 @@ function makeOdesliResponse( pageUrl: string; entitiesByUniqueId: Record; linksByPlatform: Record; + _detectedGenre: string | null; }> = {}, ) { return { entityUniqueId: 'SPOTIFY_SONG::abc123', userCountry: 'US', pageUrl: 'https://song.link/s/abc123', + _detectedGenre: null, entitiesByUniqueId: { 'SPOTIFY_SONG::abc123': { id: 'abc123', @@ -208,4 +210,22 @@ describe('resolveSongLink', () => { expect(ytMusic).toBeDefined(); expect(ytMusic!.url).toBe('https://music.youtube.com/watch?v=fJ9rUzIMcZQ'); }); + + it('should include genre from server-side _detectedGenre field', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + makeOkResponse(makeOdesliResponse({ _detectedGenre: 'electronic' })), + ); + + const result = await resolveSongLink('https://open.spotify.com/track/abc'); + expect(result.genre).toBe('electronic'); + }); + + it('should return null genre when _detectedGenre is absent', async () => { + const response = makeOdesliResponse(); + delete (response as Record)._detectedGenre; + vi.mocked(fetch).mockResolvedValueOnce(makeOkResponse(response)); + + const result = await resolveSongLink('https://open.spotify.com/track/abc'); + expect(result.genre).toBeNull(); + }); }); diff --git a/src/lib/__tests__/scoring.test.ts b/src/lib/__tests__/scoring.test.ts index 537ff44..c523561 100644 --- a/src/lib/__tests__/scoring.test.ts +++ b/src/lib/__tests__/scoring.test.ts @@ -39,6 +39,7 @@ function makeSong(overrides: Partial = {}): Song { thumbnail_url: null, platform_links: [], odesli_page_url: null, + genre: null, created_at: '2026-01-01T00:00:00Z', ...overrides, }; diff --git a/src/lib/env.ts b/src/lib/env.ts index 5193c32..6f33898 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -3,12 +3,14 @@ import * as z from 'zod'; const envSchema = z.object({ PUBLIC_SUPABASE_URL: z.url(), PUBLIC_SUPABASE_ANON_KEY: z.string().min(1, 'PUBLIC_SUPABASE_ANON_KEY is required'), + LASTFM_API_KEY: z.string().min(1).optional(), }); function validateEnv() { const result = envSchema.safeParse({ PUBLIC_SUPABASE_URL: import.meta.env.PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY: import.meta.env.PUBLIC_SUPABASE_ANON_KEY, + LASTFM_API_KEY: import.meta.env.LASTFM_API_KEY, }); if (!result.success) { diff --git a/src/lib/genres.ts b/src/lib/genres.ts new file mode 100644 index 0000000..5bc0e04 --- /dev/null +++ b/src/lib/genres.ts @@ -0,0 +1,192 @@ +export const GENRES = [ + 'rock', + 'pop', + 'hip-hop', + 'electronic', + 'jazz', + 'classical', + 'r&b', + 'latin', + 'country', + 'metal', + 'indie', + 'folk', + 'punk', + 'reggae', + 'blues', + 'soul', + 'funk', + 'alternative', + 'other', +] as const; + +export type Genre = (typeof GENRES)[number]; + +const GENRE_LABELS: Record = { + rock: 'Rock', + pop: 'Pop', + 'hip-hop': 'Hip-Hop', + electronic: 'Electronic', + jazz: 'Jazz', + classical: 'Classical', + 'r&b': 'R&B', + latin: 'Latin', + country: 'Country', + metal: 'Metal', + indie: 'Indie', + folk: 'Folk', + punk: 'Punk', + reggae: 'Reggae', + blues: 'Blues', + soul: 'Soul', + funk: 'Funk', + alternative: 'Alternative', + other: 'Other', +}; + +const GENRE_COLORS: Record = { + rock: 'bg-red-500/15 text-red-400', + pop: 'bg-pink-500/15 text-pink-400', + 'hip-hop': 'bg-amber-500/15 text-amber-400', + electronic: 'bg-cyan-500/15 text-cyan-400', + jazz: 'bg-violet-500/15 text-violet-400', + classical: 'bg-slate-500/15 text-slate-400', + 'r&b': 'bg-rose-500/15 text-rose-400', + latin: 'bg-orange-500/15 text-orange-400', + country: 'bg-yellow-500/15 text-yellow-400', + metal: 'bg-zinc-500/15 text-zinc-400', + indie: 'bg-teal-500/15 text-teal-400', + folk: 'bg-lime-500/15 text-lime-400', + punk: 'bg-fuchsia-500/15 text-fuchsia-400', + reggae: 'bg-green-500/15 text-green-400', + blues: 'bg-blue-500/15 text-blue-400', + soul: 'bg-purple-500/15 text-purple-400', + funk: 'bg-emerald-500/15 text-emerald-400', + alternative: 'bg-indigo-500/15 text-indigo-400', + other: 'bg-neutral-500/15 text-neutral-400', +}; + +const FALLBACK_COLOR = 'bg-neutral-500/15 text-neutral-400'; + +const TAG_ALIASES: Record = { + 'hip hop': 'hip-hop', + 'hip-hop': 'hip-hop', + hiphop: 'hip-hop', + rap: 'hip-hop', + trap: 'hip-hop', + rnb: 'r&b', + 'r&b': 'r&b', + 'rhythm and blues': 'r&b', + edm: 'electronic', + house: 'electronic', + techno: 'electronic', + trance: 'electronic', + dnb: 'electronic', + 'drum and bass': 'electronic', + dubstep: 'electronic', + synthpop: 'electronic', + 'indie rock': 'indie', + 'indie pop': 'indie', + 'alt-country': 'country', + 'death metal': 'metal', + 'black metal': 'metal', + 'heavy metal': 'metal', + 'thrash metal': 'metal', + 'nu metal': 'metal', + 'punk rock': 'punk', + 'post-punk': 'punk', + 'pop punk': 'punk', + hardcore: 'punk', + 'alternative rock': 'alternative', + 'alt rock': 'alternative', + grunge: 'alternative', + 'neo soul': 'soul', + 'bossa nova': 'latin', + salsa: 'latin', + reggaeton: 'latin', + bachata: 'latin', + cumbia: 'latin', + 'classic rock': 'rock', + 'hard rock': 'rock', + 'progressive rock': 'rock', + 'psychedelic rock': 'rock', + 'garage rock': 'rock', + 'blues rock': 'blues', + 'folk rock': 'folk', + acoustic: 'folk', + 'singer-songwriter': 'folk', + 'indie folk': 'folk', + 'synth-pop': 'pop', + electropop: 'pop', + 'dream pop': 'pop', + 'power pop': 'pop', + 'art pop': 'pop', + 'k-pop': 'pop', + 'j-pop': 'pop', + 'latin pop': 'latin', + 'latin rock': 'latin', + flamenco: 'latin', + tango: 'latin', + 'smooth jazz': 'jazz', + 'acid jazz': 'jazz', + 'jazz fusion': 'jazz', + 'nu jazz': 'jazz', + 'delta blues': 'blues', + 'chicago blues': 'blues', + 'electric blues': 'blues', + 'neo-soul': 'soul', + 'southern soul': 'soul', + 'psychedelic soul': 'soul', + afrobeat: 'funk', + 'g-funk': 'funk', + disco: 'funk', + 'post-rock': 'rock', + shoegaze: 'alternative', + britpop: 'alternative', + 'new wave': 'alternative', + ambient: 'electronic', + industrial: 'electronic', + electronica: 'electronic', + downtempo: 'electronic', + metalcore: 'metal', + 'doom metal': 'metal', + 'power metal': 'metal', + 'folk metal': 'metal', + 'symphonic metal': 'metal', + 'ska punk': 'punk', + emo: 'punk', + dub: 'reggae', + dancehall: 'reggae', + ska: 'reggae', + 'roots reggae': 'reggae', + gospel: 'soul', + 'country rock': 'country', + bluegrass: 'country', + americana: 'country', + 'outlaw country': 'country', +}; + +const genreSet = new Set(GENRES); + +export function getGenreLabel(genre: Genre): string { + return GENRE_LABELS[genre]; +} + +export function getGenreColor(genre: Genre): string { + return GENRE_COLORS[genre] ?? FALLBACK_COLOR; +} + +export function matchGenre(tags: string[]): Genre | null { + for (const tag of tags) { + const normalized = tag.toLowerCase().trim(); + + // 1. Check alias map + const aliased = TAG_ALIASES[normalized]; + if (aliased) return aliased; + + // 2. Check exact match against GENRES + if (genreSet.has(normalized)) return normalized as Genre; + } + + return null; +} diff --git a/src/lib/lastfm.ts b/src/lib/lastfm.ts new file mode 100644 index 0000000..680121a --- /dev/null +++ b/src/lib/lastfm.ts @@ -0,0 +1,41 @@ +import { matchGenre } from './genres'; + +const LASTFM_API = 'https://ws.audioscrobbler.com/2.0/'; +const MAX_TAGS = 10; + +async function fetchTopTags( + apiKey: string, + method: string, + params: Record, +): Promise { + try { + const qs = new URLSearchParams({ + method, + api_key: apiKey, + format: 'json', + autocorrect: '1', + ...params, + }); + const res = await fetch(`${LASTFM_API}?${qs}`); + if (!res.ok) return []; + const data = (await res.json()) as { + toptags?: { tag?: { name: string; count: number }[] }; + }; + return (data.toptags?.tag ?? []).slice(0, MAX_TAGS).map((t) => t.name); + } catch { + return []; + } +} + +export async function detectGenre(title: string, artist: string): Promise { + // Server-side only: LASTFM_API_KEY is not exposed to the client (no PUBLIC_ prefix) + const apiKey = import.meta.env.LASTFM_API_KEY ?? process.env.LASTFM_API_KEY ?? ''; + if (!apiKey) return null; + + const trackTags = await fetchTopTags(apiKey, 'track.getTopTags', { track: title, artist }); + const trackGenre = matchGenre(trackTags); + if (trackGenre) return trackGenre; + + const artistTags = await fetchTopTags(apiKey, 'artist.getTopTags', { artist }); + return matchGenre(artistTags); +} diff --git a/src/lib/odesli.ts b/src/lib/odesli.ts index 7a52132..b5499fb 100644 --- a/src/lib/odesli.ts +++ b/src/lib/odesli.ts @@ -57,6 +57,7 @@ export interface ResolvedSong { title: string; artist: string; album: string | null; + genre: string | null; thumbnailUrl: string | null; platformLinks: PlatformLink[]; pageUrl: string; @@ -173,10 +174,15 @@ export async function resolveSongLink(url: string): Promise { throw new Error('Invalid page URL returned by song resolution service.'); } + // Genre is detected server-side in /api/resolve and passed as _detectedGenre + const genre: string | null = + ((data as unknown as Record)._detectedGenre as string | null) ?? null; + return { title, artist, album: null, + genre, thumbnailUrl, platformLinks, pageUrl, diff --git a/src/lib/types.ts b/src/lib/types.ts index fc47124..b5744be 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -36,6 +36,7 @@ export interface Song { thumbnail_url: string | null; platform_links: { platform: string; url: string }[]; odesli_page_url: string | null; + genre: string | null; created_at: string; } diff --git a/src/pages/api/resolve.ts b/src/pages/api/resolve.ts index 6bebd3a..f8fe57f 100644 --- a/src/pages/api/resolve.ts +++ b/src/pages/api/resolve.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro'; import { createRateLimiter } from '../../lib/rate-limiter'; +import { detectGenre } from '../../lib/lastfm'; const ODESLI_API = 'https://api.song.link/v1-alpha.1/links'; @@ -102,8 +103,39 @@ export const GET: APIRoute = async ({ url, request }) => { }); } - return new Response(body, { - status: response.status, + if (!response.ok) { + return new Response(body, { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Parse response and detect genre server-side (Last.fm API key is not exposed to client) + let data: Record; + try { + data = JSON.parse(body); + } catch { + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let genre: string | null = null; + try { + const entities = data.entitiesByUniqueId as + | Record> + | undefined; + const primaryEntity = entities?.[data.entityUniqueId as string]; + if (primaryEntity?.title && primaryEntity?.artistName) { + genre = await detectGenre(primaryEntity.title, primaryEntity.artistName); + } + } catch { + // Genre detection is best-effort + } + + return new Response(JSON.stringify({ ...data, _detectedGenre: genre }), { + status: 200, headers: { 'Content-Type': 'application/json' }, }); } catch { diff --git a/supabase/add_genre_column.sql b/supabase/add_genre_column.sql new file mode 100644 index 0000000..ffe65ae --- /dev/null +++ b/supabase/add_genre_column.sql @@ -0,0 +1,3 @@ +-- Add optional genre field to songs table +-- Run this in the Supabase SQL Editor +ALTER TABLE songs ADD COLUMN genre text CHECK (genre IS NULL OR char_length(genre) <= 30); diff --git a/supabase/schema.sql b/supabase/schema.sql index 08b4407..20e50f7 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -43,6 +43,7 @@ create table songs ( thumbnail_url text check (thumbnail_url is null or thumbnail_url ~ '^https://'), platform_links jsonb not null default '[]', odesli_page_url text check (odesli_page_url is null or odesli_page_url ~ '^https://'), + genre text check (genre is null or char_length(genre) <= 30), created_at timestamptz default now() );