Skip to content

Commit 512d63b

Browse files
authored
Merge pull request #60 from eferro/claude/implement-roi-improvements-Lvqw5
fix: broken speaker click in TalkDetail and wire up RecentlyAddedTalks filters
2 parents 6eee888 + 6993e77 commit 512d63b

7 files changed

Lines changed: 142 additions & 31 deletions

File tree

src/components/RecentlyAddedTalks/index.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { useMemo } from 'react';
22
import { Talk } from '../../types/talks';
33
import { TalkCard } from '../TalksList/TalkCard';
4+
import { useUrlFilter } from '../../hooks/useUrlFilter';
5+
import { useFilterHandlers } from '../../hooks/useFilterHandlers';
46

57
interface RecentlyAddedTalksProps {
68
talks: Talk[];
79
maxTalks?: number;
810
}
911

1012
export function RecentlyAddedTalks({ talks, maxTalks = 6 }: RecentlyAddedTalksProps) {
13+
const { filter, updateFilter } = useUrlFilter();
14+
const { handleConferenceClick, handleTopicClick } = useFilterHandlers(filter, updateFilter);
15+
1116
const recentTalks = useMemo(() => {
1217
// Filter talks that have registered_at field
1318
const talksWithDate = talks.filter(talk => talk.registered_at);
@@ -23,10 +28,6 @@ export function RecentlyAddedTalks({ talks, maxTalks = 6 }: RecentlyAddedTalksPr
2328
return sorted.slice(0, maxTalks);
2429
}, [talks, maxTalks]);
2530

26-
// Mock handlers for TalkCard (these will be implemented properly later)
27-
const handleConferenceClick = () => {};
28-
const handleTopicClick = () => {};
29-
3031
return (
3132
<section aria-label="Recently added talks" className="mb-8 bg-gray-50 rounded-lg p-6">
3233
<h2 className="text-2xl font-bold text-gray-900 mb-6">Recently Added</h2>
@@ -39,8 +40,8 @@ export function RecentlyAddedTalks({ talks, maxTalks = 6 }: RecentlyAddedTalksPr
3940
talk={talk}
4041
onConferenceClick={handleConferenceClick}
4142
onTopicClick={handleTopicClick}
42-
selectedConference={null}
43-
selectedQuery=""
43+
selectedConference={filter.conference}
44+
selectedQuery={filter.query}
4445
/>
4546
))}
4647
</div>

src/components/TalkDetail/TalkDetail.integration.test.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,93 @@ describe('TalkDetail Integration', () => {
252252
});
253253
});
254254

255+
describe('Speaker Filter Interaction', () => {
256+
it('user can click speaker to search by speaker name', async () => {
257+
(useTalks as ReturnType<typeof vi.fn>).mockReturnValue({
258+
talks: [mockTalk],
259+
loading: false,
260+
error: null
261+
});
262+
263+
renderIntegration(
264+
<Routes>
265+
<Route path="/talk/:id" element={<TalkDetail />} />
266+
</Routes>,
267+
{
268+
initialPath: '/talk/test-123'
269+
}
270+
);
271+
272+
// Click speaker button
273+
const speakerButton = screen.getByText('Alice Smith');
274+
fireEvent.click(speakerButton);
275+
276+
// Speaker button should show active state (query set to speaker name)
277+
await waitFor(() => {
278+
expect(speakerButton).toHaveClass('bg-blue-500', 'text-white');
279+
});
280+
});
281+
282+
it('user can toggle speaker filter off by clicking again', async () => {
283+
(useTalks as ReturnType<typeof vi.fn>).mockReturnValue({
284+
talks: [mockTalk],
285+
loading: false,
286+
error: null
287+
});
288+
289+
// Start with query set to speaker name
290+
renderIntegration(
291+
<Routes>
292+
<Route path="/talk/:id" element={<TalkDetail />} />
293+
</Routes>,
294+
{
295+
initialPath: '/talk/test-123',
296+
initialParams: new URLSearchParams('query=Alice Smith')
297+
}
298+
);
299+
300+
const speakerButton = screen.getByText('Alice Smith');
301+
expect(speakerButton).toHaveClass('bg-blue-500', 'text-white');
302+
303+
// Click to remove filter
304+
fireEvent.click(speakerButton);
305+
306+
// Button should return to inactive state
307+
await waitFor(() => {
308+
expect(speakerButton).not.toHaveClass('bg-blue-500');
309+
});
310+
});
311+
312+
it('clicking a different speaker replaces the current query', async () => {
313+
(useTalks as ReturnType<typeof vi.fn>).mockReturnValue({
314+
talks: [mockTalk],
315+
loading: false,
316+
error: null
317+
});
318+
319+
// Start with query set to first speaker
320+
renderIntegration(
321+
<Routes>
322+
<Route path="/talk/:id" element={<TalkDetail />} />
323+
</Routes>,
324+
{
325+
initialPath: '/talk/test-123',
326+
initialParams: new URLSearchParams('query=Alice Smith')
327+
}
328+
);
329+
330+
// Click the other speaker
331+
const bobButton = screen.getByText('Bob Jones');
332+
fireEvent.click(bobButton);
333+
334+
// Bob should become active, Alice inactive
335+
await waitFor(() => {
336+
expect(bobButton).toHaveClass('bg-blue-500', 'text-white');
337+
});
338+
expect(screen.getByText('Alice Smith')).not.toHaveClass('bg-blue-500');
339+
});
340+
});
341+
255342
describe('Conference Filter Interaction', () => {
256343
it('user can click conference to apply filter', async () => {
257344
(useTalks as ReturnType<typeof vi.fn>).mockReturnValue({

src/components/TalkDetail/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { hasMeaningfulNotes } from '../../utils/talks';
1717
function TalkDetail() {
1818
const { id } = useParams<{ id: string }>();
1919
const { filter, updateFilter } = useUrlFilter();
20-
const { handleAuthorClick, handleConferenceClick } = useFilterHandlers(filter, updateFilter);
20+
const { handleTopicClick, handleConferenceClick } = useFilterHandlers(filter, updateFilter);
2121

2222
const { talks, loading, error } = useTalks();
2323

@@ -79,9 +79,9 @@ function TalkDetail() {
7979
{talk.speakers.map(speaker => (
8080
<button
8181
key={speaker}
82-
onClick={() => handleAuthorClick(speaker)}
82+
onClick={() => handleTopicClick(speaker)}
8383
className={`font-medium px-3 py-1 rounded-full text-sm transition-colors ${
84-
filter.author === speaker
84+
filter.query === speaker
8585
? 'bg-blue-500 text-white'
8686
: 'bg-blue-50 text-blue-700 hover:bg-blue-100'
8787
}`}

src/utils/Autocomplete.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,5 @@
11
import type { Talk } from '../types/talks';
2-
3-
/**
4-
* Normalizes text for accent-insensitive comparison.
5-
* Uses NFD normalization to decompose accented characters,
6-
* then removes combining diacritical marks.
7-
*/
8-
function normalizeForSearch(text: string): string {
9-
return text
10-
.normalize('NFD')
11-
.replace(/[\u0300-\u036f]/g, '')
12-
.toLowerCase();
13-
}
2+
import { normalizeText as normalizeForSearch } from './normalizeText';
143

154
export interface Suggestion {
165
type: 'speaker' | 'topic';

src/utils/TalksFilter.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
import { Talk } from "../types/talks";
22
import { hasMeaningfulNotes } from "./talks";
3-
4-
/**
5-
* Normalizes text for search: lowercase and removes accents
6-
*/
7-
function normalizeText(text: string): string {
8-
return text
9-
.toLowerCase()
10-
.normalize('NFD')
11-
.replace(/[\u0300-\u036f]/g, '');
12-
}
3+
import { normalizeText } from "./normalizeText";
134

145
/**
156
* Searches for query terms in multiple talk fields (title, description, speakers, topics, notes)

src/utils/normalizeText.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { normalizeText } from './normalizeText';
3+
4+
describe('normalizeText', () => {
5+
it('converts text to lowercase', () => {
6+
expect(normalizeText('Hello World')).toBe('hello world');
7+
});
8+
9+
it('removes accents from characters', () => {
10+
expect(normalizeText('café')).toBe('cafe');
11+
expect(normalizeText('résumé')).toBe('resume');
12+
expect(normalizeText('naïve')).toBe('naive');
13+
});
14+
15+
it('handles combined accents and uppercase', () => {
16+
expect(normalizeText('José García')).toBe('jose garcia');
17+
expect(normalizeText('André François')).toBe('andre francois');
18+
});
19+
20+
it('preserves non-accented characters', () => {
21+
expect(normalizeText('hello world 123')).toBe('hello world 123');
22+
});
23+
24+
it('handles empty string', () => {
25+
expect(normalizeText('')).toBe('');
26+
});
27+
28+
it('handles special unicode characters like ñ and ü', () => {
29+
expect(normalizeText('España')).toBe('espana');
30+
expect(normalizeText('über')).toBe('uber');
31+
});
32+
});

src/utils/normalizeText.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Normalizes text for accent-insensitive, case-insensitive comparison.
3+
* Uses NFD normalization to decompose accented characters,
4+
* then removes combining diacritical marks.
5+
*/
6+
export function normalizeText(text: string): string {
7+
return text
8+
.toLowerCase()
9+
.normalize('NFD')
10+
.replace(/[\u0300-\u036f]/g, '');
11+
}

0 commit comments

Comments
 (0)