Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
310c472
feat: add genre constants module with tag matching for Last.fm
alvarotorresc Mar 3, 2026
3287e2b
feat: add genre column to songs table and Song type
alvarotorresc Mar 3, 2026
00e0cc6
feat: add Last.fm genre detection with track/artist fallback
alvarotorresc Mar 3, 2026
08817e4
feat: integrate Last.fm genre detection into song resolution
alvarotorresc Mar 3, 2026
a684afe
feat: add GenreBadge UI component
alvarotorresc Mar 3, 2026
fa36e9a
feat: add genre picker to AddSong form
alvarotorresc Mar 3, 2026
fe566bb
feat: wire genre through addSong flow to Supabase
alvarotorresc Mar 3, 2026
2740523
feat: display genre badge in SongCard
alvarotorresc Mar 3, 2026
2412df4
feat(leaderboard): show genre badges in past rounds
alvarotorresc Mar 3, 2026
5cad017
feat: add genre filter to leaderboard
alvarotorresc Mar 3, 2026
b6fbad8
feat: add i18n keys for genre feature and LASTFM env var
alvarotorresc Mar 3, 2026
1367879
fix: add android to eslint ignores and allow console.error/warn
alvarotorresc Mar 3, 2026
ad138e6
fix: remove unsafe substring matching in matchGenre
alvarotorresc Mar 3, 2026
98d95fa
fix: move genre detection to server-side /api/resolve
alvarotorresc Mar 3, 2026
606d646
fix: add genre whitelist validation and env schema
alvarotorresc Mar 3, 2026
a59ebac
test: improve test quality and coverage from review
alvarotorresc Mar 3, 2026
abde4a0
merge: integrate origin/main into feature/song-genres
alvarotorresc Mar 3, 2026
9edae56
fix: correct genre select aria-label in AddSong tests
alvarotorresc Mar 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default [
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
];
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions src/components/ui/GenreBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { GENRES, getGenreColor, getGenreLabel, type Genre } from '../../lib/genres';

interface GenreBadgeProps {
genre: string | null;
}

const genreSet = new Set<string>(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 (
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${colorClasses}`}>
{label}
</span>
);
}
27 changes: 27 additions & 0 deletions src/components/ui/GenreFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
aria-label={t('leaderboard.genreFilter', locale)}
className="rounded-lg border border-border bg-bg-input px-3 py-2 text-sm text-text"
>
<option value="">{t('leaderboard.allGenres', locale)}</option>
{GENRES.map((g) => (
<option key={g} value={g}>
{getGenreLabel(g)}
</option>
))}
</select>
);
}
36 changes: 36 additions & 0 deletions src/components/ui/__tests__/GenreBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<GenreBadge genre="rock" />);
expect(screen.getByText('Rock')).toBeInTheDocument();
});

it('should apply genre-specific color classes', () => {
const { container } = render(<GenreBadge genre="rock" />);
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(<GenreBadge genre={null} />);
expect(container.firstElementChild).toBeNull();
});

it('should apply fallback color for unknown genre', () => {
const { container } = render(<GenreBadge genre="unknown-genre" />);
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(<GenreBadge genre="unknown-genre" />);
expect(screen.getByText('unknown-genre')).toBeInTheDocument();
});
});
55 changes: 55 additions & 0 deletions src/components/ui/__tests__/GenreFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<GenreFilter value={null} onChange={vi.fn()} locale="en" />);
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(<GenreFilter value={null} onChange={vi.fn()} locale="en" />);
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(<GenreFilter value={null} onChange={onChange} locale="en" />);

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(<GenreFilter value="rock" onChange={onChange} locale="en" />);

await userEvent.selectOptions(screen.getByRole('combobox'), '');
expect(onChange).toHaveBeenCalledWith(null);
});

it('should have accessible label', () => {
render(<GenreFilter value={null} onChange={vi.fn()} locale="en" />);
expect(screen.getByLabelText('Filter by genre')).toBeInTheDocument();
});

it('should show Spanish labels when locale is es', () => {
render(<GenreFilter value={null} onChange={vi.fn()} locale="es" />);
expect(screen.getByLabelText('Filtrar por genero')).toBeInTheDocument();
expect(screen.getByText('Todos los generos')).toBeDefined();
});

it('should reflect the current value', () => {
render(<GenreFilter value="jazz" onChange={vi.fn()} locale="en" />);
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('jazz');
});
});
40 changes: 31 additions & 9 deletions src/features/group/AddSong.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
onAddSong: (url: string, genre: string | null) => Promise<void>;
locale: Locale;
}

Expand All @@ -16,6 +18,7 @@ type AddSongState = 'idle' | 'resolving' | 'error';
*/
export function AddSong({ onAddSong, locale }: AddSongProps) {
const [url, setUrl] = useState('');
const [genre, setGenre] = useState<string>('');
const [state, setState] = useState<AddSongState>('idle');
const [errorMessage, setErrorMessage] = useState('');

Expand All @@ -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';
Expand All @@ -41,7 +45,7 @@ export function AddSong({ onAddSong, locale }: AddSongProps) {

return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<div className="flex gap-2 rounded-xl border border-dashed border-border bg-bg-card p-4">
<div className="flex flex-col gap-2 rounded-xl border border-dashed border-border bg-bg-card p-4">
<label htmlFor="add-song-url" className="sr-only">
{t('group.addSong.placeholder', locale)}
</label>
Expand All @@ -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"
/>
<Button type="submit" size="sm" disabled={state === 'resolving' || !url.trim()}>
{state === 'resolving'
? t('group.addSong.resolving', locale)
: t('group.addSong.button', locale)}
</Button>
<div className="flex gap-2">
<select
value={genre}
onChange={(e) => setGenre(e.target.value)}
disabled={state === 'resolving'}
aria-label={t('group.addSong.genreLabel' as TranslationKey, locale)}
className="flex-1 rounded-lg border border-border bg-bg-input px-3 py-2.5 text-sm text-text"
>
<option value="">
{t('group.addSong.genrePlaceholder' as TranslationKey, locale)}
</option>
{GENRES.map((g) => (
<option key={g} value={g}>
{getGenreLabel(g)}
</option>
))}
</select>
<Button type="submit" size="sm" disabled={state === 'resolving' || !url.trim()}>
{state === 'resolving'
? t('group.addSong.resolving', locale)
: t('group.addSong.button', locale)}
</Button>
</div>
</div>

{state === 'error' && errorMessage && (
Expand Down
7 changes: 7 additions & 0 deletions src/features/group/SongCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,6 +73,12 @@ export function SongCard({ song, memberId, members, onRate, locale }: SongCardPr
<h3 className="truncate text-base font-semibold text-text">{song.title}</h3>
<p className="mb-2 truncate text-sm text-text-secondary">{song.artist}</p>

{song.genre && (
<div className="mb-1.5">
<GenreBadge genre={song.genre} />
</div>
)}

{/* Added by */}
<p className="mb-2.5 text-xs text-text-tertiary">
{t('group.songCard.addedBy', locale)}{' '}
Expand Down
51 changes: 47 additions & 4 deletions src/features/group/__tests__/AddSong.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AddSong } from '../AddSong';

describe('AddSong', () => {
let mockOnAddSong: ReturnType<typeof vi.fn<(url: string) => Promise<void>>>;
let mockOnAddSong: ReturnType<typeof vi.fn<(url: string, genre: string | null) => Promise<void>>>;

beforeEach(() => {
mockOnAddSong = vi.fn<(url: string) => Promise<void>>();
mockOnAddSong = vi.fn<(url: string, genre: string | null) => Promise<void>>();
});

it('should render input and submit button', () => {
Expand All @@ -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(<AddSong onAddSong={mockOnAddSong} locale="en" />);

Expand All @@ -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);
});
});

Expand Down Expand Up @@ -162,4 +162,47 @@ describe('AddSong', () => {

expect(mockOnAddSong).not.toHaveBeenCalled();
});

it('should render genre select dropdown', () => {
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const select = screen.getByRole('combobox', { name: 'Genre' });
expect(select).toBeDefined();
});

it('should call onAddSong with selected genre on submit', async () => {
mockOnAddSong.mockResolvedValue(undefined);
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

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(<AddSong onAddSong={mockOnAddSong} locale="en" />);

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('');
});
});
});
Loading