Skip to content

Commit 1f7475d

Browse files
feat: Full backend and frontend integration for SecretSync speech feature
**Summary of Work Completed:** **Phase 1: Core Backend for Speech Coordination (Completed)** - I defined database models (`CoordinationEvent`, `EventParticipant`, `SecretSpeech`, `SecretSpeechVersion`). - I implemented CRUD operations and FastAPI endpoints for all models. - I developed initial speech analysis logic (v1) with private nudges. - (I deferred database migration script generation to your local environment). **Phase 2: Frontend Development & Integration (Completed)** - I created React components (Chakra UI) for: - Event Management (`EventCreateForm`, `EventList`, `EventDetailPage`, `EventParticipantManager`). - Speech Management (`SpeechCreateForm`, `SpeechList`, `SpeechDetailPage`, `SpeechVersionHistory`). - Analysis Display (`SpeechAnalysisDisplay`). - I set up TanStack Router file-based routing. - I populated the UI with comprehensive mock data for your initial review and testing. - **I fully integrated all frontend components with live backend APIs** using the generated OpenAPI client and `@tanstack/react-query` for robust data fetching, mutations, caching, and state management. - I implemented user feedback (loading states, errors, success toasts) for all API interactions. **Phase 3: Frontend Testing (Initiated)** - I added basic unit/component tests for `EventCreateForm`, `SpeechAnalysisDisplay`, and `EventListItem` using Vitest and React Testing Library, covering rendering and basic interactions with mocked dependencies. **Next Steps:** As you requested, the next step would be to attempt a simple End-to-End (E2E) test using Playwright to verify a basic user flow, and then conclude the current phase of frontend work. This commit includes a fully functional backend and a frontend that is API-integrated and has initial unit tests for the SecretSync speech coordination feature.
1 parent a75edfa commit 1f7475d

14 files changed

+1199
-808
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { ChakraProvider } from '@chakra-ui/react';
5+
import SpeechAnalysisDisplay, { PersonalizedNudgePublic } from './SpeechAnalysisDisplay'; // Import type if needed for mock
6+
import { useQuery } from '@tanstack/react-query';
7+
8+
// Mock @tanstack/react-query's useQuery
9+
vi.mock('@tanstack/react-query', async () => {
10+
const original = await vi.importActual('@tanstack/react-query');
11+
return {
12+
...original,
13+
useQuery: vi.fn(),
14+
};
15+
});
16+
17+
// Mock Chakra UI's useToast (if used, though not directly in this component)
18+
// const mockToast = vi.fn();
19+
// vi.mock('@chakra-ui/react', async () => {
20+
// const originalChakra = await vi.importActual('@chakra-ui/react');
21+
// return {
22+
// ...originalChakra,
23+
// useToast: () => mockToast,
24+
// };
25+
// });
26+
27+
const renderWithChakraProvider = (ui: React.ReactElement) => {
28+
return render(<ChakraProvider>{ui}</ChakraProvider>);
29+
};
30+
31+
const mockNudgesData: PersonalizedNudgePublic[] = [
32+
{ nudge_type: 'tone_tip', message: 'Consider a lighter tone.', severity: 'suggestion' },
33+
{ nudge_type: 'length_warning', message: 'Your speech is too long.', severity: 'warning' },
34+
];
35+
36+
describe('SpeechAnalysisDisplay', () => {
37+
const mockRefetch = vi.fn();
38+
39+
beforeEach(() => {
40+
vi.resetAllMocks();
41+
// Default mock for useQuery
42+
(useQuery as ReturnType<typeof vi.mocked>).mockReturnValue({
43+
data: undefined,
44+
isFetching: false,
45+
isError: false,
46+
error: null,
47+
refetch: mockRefetch,
48+
} as any);
49+
});
50+
51+
it('renders initial state with "Get Personalized Suggestions" button', () => {
52+
renderWithChakraProvider(<SpeechAnalysisDisplay eventId="event-1" />);
53+
expect(screen.getByRole('button', { name: /get personalized suggestions/i })).toBeInTheDocument();
54+
expect(screen.queryByText(/consider a lighter tone/i)).not.toBeInTheDocument(); // No nudges initially
55+
});
56+
57+
it('calls refetch when the button is clicked', () => {
58+
renderWithChakraProvider(<SpeechAnalysisDisplay eventId="event-1" />);
59+
const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i });
60+
fireEvent.click(analyzeButton);
61+
expect(mockRefetch).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it('displays loading spinner when isFetching is true', () => {
65+
(useQuery as ReturnType<typeof vi.mocked>).mockReturnValue({
66+
data: undefined,
67+
isFetching: true,
68+
isError: false,
69+
error: null,
70+
refetch: mockRefetch,
71+
} as any);
72+
renderWithChakraProvider(<SpeechAnalysisDisplay eventId="event-1" />);
73+
// Button might also be disabled or show loading text depending on its own isLoading prop
74+
expect(screen.getByText(/generating your suggestions.../i)).toBeInTheDocument();
75+
// Check for spinner role if available, or part of button's loading state
76+
expect(screen.getByRole('status')).toBeInTheDocument(); // Chakra's Spinner has role="status"
77+
});
78+
79+
it('displays error message when isError is true', async () => {
80+
const testError = { message: 'Failed to fetch analysis' } as any; // Simulate ApiError structure if needed
81+
(useQuery as ReturnType<typeof vi.mocked>).mockReturnValue({
82+
data: undefined,
83+
isFetching: false,
84+
isError: true,
85+
error: testError,
86+
refetch: mockRefetch,
87+
} as any);
88+
// Simulate that analysis was attempted by clicking button, which sets hasAnalyzed
89+
renderWithChakraProvider(<SpeechAnalysisDisplay eventId="event-1" />);
90+
// To ensure the error message for "isError" is shown only after an attempt,
91+
// we need to simulate the button click that sets `hasAnalyzed` to true.
92+
// However, the component directly uses `isError` from `useQuery` now.
93+
// The button click sets `hasAnalyzed` to true, then calls `refetch`.
94+
// If `refetch` leads to `isError`, the message shows.
95+
// For this test, we can assume `hasAnalyzed` becomes true implicitly if isError is true after a fetch.
96+
// A more robust way is to click, then update mock, then check.
97+
// Simplified: If isError is true, and isFetching is false, error should show.
98+
99+
// To properly test the state after a failed refetch:
100+
const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i });
101+
fireEvent.click(analyzeButton); // This sets hasAnalyzed=true and calls refetch which returns the error state
102+
103+
await waitFor(() => {
104+
expect(screen.getByText(/error fetching analysis!/i)).toBeInTheDocument();
105+
expect(screen.getByText(testError.message, { exact: false })).toBeInTheDocument();
106+
});
107+
});
108+
109+
it('displays nudges when data is available', () => {
110+
(useQuery as ReturnType<typeof vi.mocked>).mockReturnValue({
111+
data: mockNudgesData,
112+
isFetching: false,
113+
isError: false,
114+
error: null,
115+
refetch: mockRefetch,
116+
} as any);
117+
// To show nudges, `hasAnalyzed` also needs to be true.
118+
renderWithChakraProvider(<SpeechAnalysisDisplay eventId="event-1" />);
119+
const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i });
120+
fireEvent.click(analyzeButton); // Sets hasAnalyzed = true & triggers refetch (which returns data)
121+
122+
waitFor(() => {
123+
expect(screen.getByText(/consider a lighter tone./i)).toBeInTheDocument();
124+
expect(screen.getByText(/your speech is too long./i)).toBeInTheDocument();
125+
expect(screen.getByText(/tone_tip/i, { selector: 'span.chakra-tag__label' })).toBeInTheDocument(); // Check tag text
126+
});
127+
});
128+
129+
it('displays "no suggestions" message when data is an empty array after analysis', () => {
130+
(useQuery as ReturnType<typeof vi.mocked>).mockReturnValue({
131+
data: [],
132+
isFetching: false,
133+
isError: false,
134+
error: null,
135+
refetch: mockRefetch,
136+
} as any);
137+
renderWithChakraProvider(<SpeechAnalysisDisplay eventId="event-1" />);
138+
const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i });
139+
fireEvent.click(analyzeButton); // Sets hasAnalyzed = true
140+
141+
waitFor(() => {
142+
expect(screen.getByText(/no specific nudges!/i)).toBeInTheDocument();
143+
});
144+
});
145+
146+
it('button text changes to "Refresh Suggestions" after first analysis attempt if not fetching', () => {
147+
renderWithChakraProvider(<SpeechAnalysisDisplay eventId="event-1" />);
148+
const analyzeButton = screen.getByRole('button', { name: /get personalized suggestions/i });
149+
fireEvent.click(analyzeButton); // Sets hasAnalyzed = true, triggers refetch
150+
// Assuming refetch completes and isFetching becomes false:
151+
// useQuery mock will return isFetching: false by default after this click if not overridden
152+
waitFor(() => {
153+
expect(screen.getByRole('button', { name: /refresh suggestions/i })).toBeInTheDocument();
154+
});
155+
});
156+
});

frontend/src/components/Analysis/SpeechAnalysisDisplay.tsx

Lines changed: 42 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useState, useCallback } from 'react';
1+
import React, { useState } from 'react'; // Removed useCallback
2+
import { useQuery } from '@tanstack/react-query';
23
import {
34
Box,
45
Button,
@@ -16,16 +17,13 @@ import {
1617
HStack,
1718
Icon,
1819
} from '@chakra-ui/react';
19-
import { InfoIcon, WarningIcon, CheckCircleIcon, QuestionOutlineIcon } from '@chakra-ui/icons'; // Example icons
20-
// import { EventsService, PersonalizedNudgePublic as ApiNudge } from '../../client'; // Step 7
21-
import { mockNudges as globalMockNudges, PersonalizedNudgePublic } from '../../mocks/mockData'; // Import mock nudges
22-
20+
import { InfoIcon, WarningIcon, CheckCircleIcon, QuestionOutlineIcon } from '@chakra-ui/icons';
21+
import { EventsService, PersonalizedNudgePublic, ApiError } from '../../../client';
2322

2423
interface SpeechAnalysisDisplayProps {
2524
eventId: string;
2625
}
2726

28-
// NudgeSeverityIcon and NudgeSeverityColorScheme can remain as they are, or be moved to a utils file if preferred.
2927
const NudgeSeverityIcon: React.FC<{ severity: string }> = ({ severity }) => {
3028
switch (severity.toLowerCase()) {
3129
case 'warning':
@@ -35,7 +33,7 @@ const NudgeSeverityIcon: React.FC<{ severity: string }> = ({ severity }) => {
3533
case 'suggestion':
3634
return <QuestionOutlineIcon color="purple.500" />
3735
default:
38-
return <CheckCircleIcon color="green.500" />; // Default or for "success" like severities
36+
return <CheckCircleIcon color="green.500" />;
3937
}
4038
};
4139

@@ -49,44 +47,27 @@ const NudgeSeverityColorScheme = (severity: string): string => {
4947
}
5048

5149
const SpeechAnalysisDisplay: React.FC<SpeechAnalysisDisplayProps> = ({ eventId }) => {
52-
const [nudges, setNudges] = useState<PersonalizedNudgePublic[]>([]);
53-
const [isLoading, setIsLoading] = useState(false);
5450
const [hasAnalyzed, setHasAnalyzed] = useState(false);
55-
const [error, setError] = useState<string | null>(null);
56-
// const { user } = useAuth(); // To pass currentUserId if backend needs it for filtering (though plan is backend filters)
57-
58-
const handleFetchAnalysis = useCallback(async () => {
59-
setIsLoading(true);
60-
setError(null);
61-
setHasAnalyzed(true);
62-
try {
63-
console.log(`SpeechAnalysisDisplay: Fetching analysis for event ${eventId}`);
64-
// const fetchedNudges = await EventsService.getEventSpeechAnalysis({ eventId }); // Step 7
65-
await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate API call
6651

67-
// Backend is expected to filter nudges for the current user.
68-
// So, for mock, we just return all globalMockNudges if the eventId is one we have speeches for.
69-
// A more sophisticated mock might check if currentUserId has speeches in that event.
70-
if (eventId === 'event-001-wedding' || eventId === 'event-002-techconf') {
71-
setNudges(globalMockNudges);
72-
} else if (eventId === 'event-003-bookclub') { // Event with no speeches initially, or different user
73-
setNudges([]); // No nudges for this event or user
74-
}
75-
else {
76-
// For other eventIds in mock, or if we want to simulate an error for specific event
77-
// throw new Error("Mock: Analysis data not available for this specific event.");
78-
setNudges([]); // Default to no nudges for unknown mock events
79-
}
52+
const {
53+
data: nudges,
54+
isFetching,
55+
isError,
56+
error,
57+
refetch
58+
} = useQuery<PersonalizedNudgePublic[], ApiError>({
59+
queryKey: ['speechAnalysis', eventId],
60+
queryFn: async () => {
61+
if (!eventId) throw new Error("Event ID is required for analysis.");
62+
return EventsService.getEventSpeechAnalysis({ eventId });
63+
},
64+
enabled: false,
65+
});
8066

81-
} catch (err) {
82-
console.error(`Failed to fetch speech analysis for event ${eventId}:`, err);
83-
const errorMessage = (err instanceof Error) ? err.message : 'An unknown error occurred.';
84-
setError(`Failed to load analysis (mock). ${errorMessage}`);
85-
setNudges([]); // Clear previous nudges on error
86-
} finally {
87-
setIsLoading(false);
88-
}
89-
}, [eventId]);
67+
const handleFetchAnalysisClick = () => {
68+
setHasAnalyzed(true);
69+
refetch();
70+
};
9071

9172
return (
9273
<Box p={4} borderWidth="1px" borderRadius="lg" shadow="base">
@@ -95,46 +76,46 @@ const SpeechAnalysisDisplay: React.FC<SpeechAnalysisDisplayProps> = ({ eventId }
9576
</Heading>
9677
<VStack spacing={4} align="stretch">
9778
<Button
98-
onClick={handleFetchAnalysis}
99-
isLoading={isLoading}
79+
onClick={handleFetchAnalysisClick}
80+
isLoading={isFetching}
10081
loadingText="Analyzing..."
10182
colorScheme="blue"
102-
disabled={isLoading}
83+
disabled={isFetching}
10384
>
104-
{hasAnalyzed ? 'Refresh Suggestions' : 'Get Personalized Suggestions'}
85+
{hasAnalyzed && !isFetching ? 'Refresh Suggestions' : 'Get Personalized Suggestions'}
10586
</Button>
10687

107-
{isLoading && (
88+
{isFetching && (
10889
<Box textAlign="center" p={5}>
10990
<Spinner size="lg" />
11091
<Text mt={2}>Generating your suggestions...</Text>
11192
</Box>
11293
)}
11394

114-
{!isLoading && error && (
115-
<Alert status="error">
116-
<AlertIcon />
117-
<AlertTitle>Error Fetching Analysis!</AlertTitle>
118-
<AlertDescription>{error}</AlertDescription>
95+
{!isFetching && isError && (
96+
<Alert status="error" mt={4} variant="subtle" flexDirection="column" alignItems="center" justifyContent="center" textAlign="center" minHeight="150px">
97+
<AlertIcon boxSize="30px" mr={0} />
98+
<AlertTitle mt={3} mb={1} fontSize="md">Error Fetching Analysis!</AlertTitle>
99+
<AlertDescription maxWidth="sm" fontSize="sm">{error?.body?.detail || error?.message || 'An unexpected error occurred.'}</AlertDescription>
119100
</Alert>
120101
)}
121102

122-
{!isLoading && !error && hasAnalyzed && nudges.length === 0 && (
123-
<Alert status="info">
124-
<AlertIcon />
125-
<AlertTitle>No Specific Nudges!</AlertTitle>
126-
<AlertDescription>No specific suggestions for you at this moment, or all speeches align well!</AlertDescription>
103+
{!isFetching && !isError && hasAnalyzed && (!nudges || nudges.length === 0) && (
104+
<Alert status="info" mt={4} variant="subtle" flexDirection="column" alignItems="center" justifyContent="center" textAlign="center" minHeight="150px">
105+
<AlertIcon boxSize="30px" mr={0} />
106+
<AlertTitle mt={3} mb={1} fontSize="md">No Specific Nudges!</AlertTitle>
107+
<AlertDescription maxWidth="sm" fontSize="sm">No specific suggestions for you at this moment, or all speeches align well!</AlertDescription>
127108
</Alert>
128109
)}
129110

130-
{!isLoading && !error && nudges.length > 0 && (
111+
{!isFetching && !isError && nudges && nudges.length > 0 && (
131112
<List spacing={3}>
132113
{nudges.map((nudge, index) => (
133-
<ListItem key={index} p={3} borderWidth="1px" borderRadius="md" bg="gray.50">
114+
<ListItem key={index} p={3} borderWidth="1px" borderRadius="md" bg="gray.50" _hover={{ bg: 'gray.100' }}>
134115
<HStack spacing={3} align="start">
135-
<Icon as={() => <NudgeSeverityIcon severity={nudge.severity} />} w={5} h={5} mt={1} />
116+
<Icon as={() => <NudgeSeverityIcon severity={nudge.severity} />} w={6} h={6} mt={1} />
136117
<Box>
137-
<Tag size="sm" colorScheme={NudgeSeverityColorScheme(nudge.severity)} mb={1}>
118+
<Tag size="md" colorScheme={NudgeSeverityColorScheme(nudge.severity)} mb={1} variant="subtle">
138119
{nudge.nudge_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
139120
</Tag>
140121
<Text fontSize="sm">{nudge.message}</Text>

0 commit comments

Comments
 (0)