session start works

This commit is contained in:
aodulov
2025-10-10 12:48:06 +03:00
parent 556df015e8
commit 3c192b136c
51 changed files with 29002 additions and 46 deletions

0
frontend/.dockerignore Normal file
View File

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Stage 1: Build the React application
FROM node:18-alpine AS build
WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application source code
COPY . .
# Build the application
RUN npm run build
# Stage 2: Serve the application with Nginx
FROM nginx:1.21-alpine
# Copy the built application from the build stage
COPY --from=build /app/build /usr/share/nginx/html
# Copy a custom Nginx configuration if needed (optional)
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start Nginx
CMD ["nginx", "-g", "daemon off;"]

16
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html index.htm;
location / {
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

21022
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
frontend/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "unisono",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.14.18",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.61",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.19.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"uuid": "^9.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/uuid": "^9.0.7"
}
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="A real-time app for collaborative decision-making."
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Unisono</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

24
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,24 @@
import React from 'react';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import theme from './theme';
import CreateSession from './pages/CreateSession';
import SessionPage from './pages/SessionPage';
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
<Routes>
<Route path="/" element={<CreateSession />} />
{/* Other routes will be added here */}
<Route path="/session/:sessionId" element={<SessionPage />} />
</Routes>
</Router>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import DesireForm from './DesireForm';
test('renders desire submission form with input fields and submit button', () => {
render(<DesireForm onSubmit={() => {}} />);
// Check for headings/labels for each category
expect(screen.getByLabelText(/What you WANT/i)).toBeInTheDocument();
expect(screen.getByLabelText(/What you ACCEPT/i)).toBeInTheDocument();
expect(screen.getByLabelText(/What you DO NOT WANT/i)).toBeInTheDocument();
// Check for the submit button
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
expect(submitButton).toBeInTheDocument();
});

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Typography } from '@mui/material';
interface DesireFormProps {
onSubmit: (desires: { wants: string[], accepts: string[], noGoes: string[] }) => void;
}
const DesireForm: React.FC<DesireFormProps> = ({ onSubmit }) => {
const [wants, setWants] = useState('');
const [accepts, setAccepts] = useState('');
const [noGoes, setNoGoes] = useState('');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const parsedWants = wants.split('\n').map(s => s.trim()).filter(s => s);
const parsedAccepts = accepts.split('\n').map(s => s.trim()).filter(s => s);
const parsedNoGoes = noGoes.split('\n').map(s => s.trim()).filter(s => s);
// FR-020: The system MUST require a user to enter at least one desire in at least one of the three categories
if (parsedWants.length === 0 && parsedAccepts.length === 0 && parsedNoGoes.length === 0) {
alert('Please enter at least one desire in any category.');
return;
}
// FR-016: System MUST validate a user's submission to prevent the same item from appearing in conflicting categories
const allItems = [...parsedWants, ...parsedAccepts, ...parsedNoGoes];
const uniqueItems = new Set(allItems);
if (allItems.length !== uniqueItems.size) {
alert('You have conflicting desires (same item in different categories). Please resolve.');
return;
}
onSubmit({
wants: parsedWants,
accepts: parsedAccepts,
noGoes: parsedNoGoes,
});
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>What you WANT</Typography>
<TextField
label="List items you want (one per line)"
multiline
rows={4}
fullWidth
value={wants}
onChange={(e) => setWants(e.target.value)}
margin="normal"
/>
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>What you ACCEPT</Typography>
<TextField
label="List items you accept (one per line)"
multiline
rows={4}
fullWidth
value={accepts}
onChange={(e) => setAccepts(e.target.value)}
margin="normal"
/>
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>What you DO NOT WANT</Typography>
<TextField
label="List items you absolutely do not want (one per line)"
multiline
rows={4}
fullWidth
value={noGoes}
onChange={(e) => setNoGoes(e.target.value)}
margin="normal"
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Submit Desires
</Button>
</Box>
);
};
export default DesireForm;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ResultsDisplay from './ResultsDisplay';
import { Decision } from '../hooks/useSession';
const mockDecision: Decision = {
goTos: [{ title: 'Go to the beach', rawInputs: ['beach'] }],
alsoGoods: [{ title: 'Eat pizza', rawInputs: ['pizza'] }],
considerables: [{ title: 'Watch a movie', rawInputs: ['movie'] }],
noGoes: [{ title: 'Stay home', rawInputs: ['home'] }],
};
describe('ResultsDisplay', () => {
it('renders all categories correctly', () => {
render(<ResultsDisplay decision={mockDecision} />);
expect(screen.getByText('Go-to')).toBeInTheDocument();
expect(screen.getByText('Go to the beach')).toBeInTheDocument();
expect(screen.getByText('Also good')).toBeInTheDocument();
expect(screen.getByText('Eat pizza')).toBeInTheDocument();
expect(screen.getByText('Considerable')).toBeInTheDocument();
expect(screen.getByText('Watch a movie')).toBeInTheDocument();
expect(screen.getByText('No-goes')).toBeInTheDocument();
expect(screen.getByText('Stay home')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Box, Typography, List, ListItem, ListItemText, Collapse, IconButton } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { Decision, SemanticDesire } from '../hooks/useSession';
interface ResultsDisplayProps {
decision: Decision;
}
const CategorySection: React.FC<{ title: string; desires: SemanticDesire[]; defaultExpanded?: boolean }>
= ({ title, desires, defaultExpanded = true }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded);
if (!desires || desires.length === 0) {
return null;
}
return (
<Box sx={{ mt: 3, border: '1px solid #e0e0e0', borderRadius: '4px', p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="h2" gutterBottom>
{title}
</Typography>
<IconButton onClick={() => setExpanded(!expanded)} size="small">
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<List dense>
{desires.map((desire, index) => (
<ListItem key={index}>
<ListItemText primary={desire.title} />
</ListItem>
))}
</List>
</Collapse>
</Box>
);
};
const ResultsDisplay: React.FC<ResultsDisplayProps> = ({ decision }) => {
if (!decision) {
return <Typography>No decision available yet.</Typography>;
}
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h5" component="h1" gutterBottom>
Cooperative Decision
</Typography>
<CategorySection title="Go-to" desires={decision.goTos} />
<CategorySection title="Also good" desires={decision.alsoGoods} />
<CategorySection title="Considerable" desires={decision.considerables} defaultExpanded={false} />
<CategorySection title="No-goes" desires={decision.noGoes} />
</Box>
);
};
export default ResultsDisplay;

View File

@@ -0,0 +1,115 @@
import { useState, useEffect, useCallback, Dispatch, SetStateAction } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { webSocketService } from '../services/websocket'; // Import the websocket service
// Define the types for the session state based on data-model.md
// In a real app, these would be in a separate types file.
export interface Participant {
participantId: string; // This will now be the clientId
isCreator: boolean;
hasSubmitted: boolean;
}
export interface DesireSet {
participantId: string;
wants: string[];
accepts: string[];
noGoes: string[];
}
export interface SemanticDesire {
title: string;
rawInputs: string[];
}
export interface Decision {
goTos: SemanticDesire[];
alsoGoods: SemanticDesire[];
considerables: SemanticDesire[];
noGoes: SemanticDesire[];
}
// Define the SessionState enum (mirroring backend)
export enum SessionState {
SETUP = 'SETUP',
GATHERING = 'GATHERING',
HARMONIZING = 'HARMONIZING',
FINAL = 'FINAL',
}
export interface Session {
sessionId: string;
state: SessionState;
expectedResponses: number;
submittedCount: number;
responses: { [clientId: string]: boolean }; // Map of clientId to a boolean indicating if they submitted
finalResult: Decision | null;
topic?: string; // This might be part of the initial setup payload
}
// Utility to generate a persistent client ID
const getOrCreateClientId = (): string => {
let clientId = localStorage.getItem('clientId');
if (!clientId) {
clientId = uuidv4();
localStorage.setItem('clientId', clientId);
}
return clientId;
};
export const useSession = (sessionId: string): [Session | null, Dispatch<SetStateAction<Session | null>>, (message: any) => void, string] => {
const clientId = getOrCreateClientId(); // Get or create clientId
const getInitialState = useCallback((): Session | null => {
try {
const item = window.localStorage.getItem(`session-${sessionId}`);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error('Error reading from localStorage', error);
return null;
}
}, [sessionId]);
const [session, setSession] = useState<Session | null>(getInitialState);
useEffect(() => {
// Connect to WebSocket
webSocketService.connect(sessionId, clientId);
// Handle incoming messages
const handleMessage = (message: any) => {
console.log('useSession: Processing incoming message:', message);
if (message.type === 'STATE_UPDATE') {
setSession(message.payload.session);
} else if (message.type === 'ERROR') {
console.error('WebSocket Error:', message.payload.message);
// Optionally, handle error display to the user
}
// Add other message types as needed
};
webSocketService.onMessage(handleMessage);
// Clean up on unmount
return () => {
webSocketService.removeMessageHandler(handleMessage);
webSocketService.disconnect();
};
}, [sessionId, clientId]); // Re-run effect if sessionId or clientId changes
useEffect(() => {
try {
if (session) {
window.localStorage.setItem(`session-${sessionId}`, JSON.stringify(session));
}
} catch (error) {
console.error('Error writing to localStorage', error);
}
}, [session, sessionId]);
const sendMessage = useCallback((message: any) => {
webSocketService.sendMessage(message);
}, []);
return [session, setSession, sendMessage, clientId];
};

12
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import CreateSession from './CreateSession';
test('renders create session page with a form', () => {
render(<CreateSession />);
// Check for a heading
const headingElement = screen.getByText(/Create a New Session/i);
expect(headingElement).toBeInTheDocument();
// Check for form fields
const topicInput = screen.getByLabelText(/Topic/i);
expect(topicInput).toBeInTheDocument();
const participantsInput = screen.getByLabelText(/Number of Participants/i);
expect(participantsInput).toBeInTheDocument();
// Check for the create button
const createButton = screen.getByRole('button', { name: /Create Session/i });
expect(createButton).toBeInTheDocument();
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Box, Button, Typography, Container } from '@mui/material';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
const CreateSession = () => {
const navigate = useNavigate();
const handleCreate = async () => {
try {
const response = await axios.post('http://localhost:8000/sessions');
const { sessionId } = response.data;
navigate(`/session/${sessionId}`);
} catch (error) {
console.error('Error creating session:', error);
}
};
return (
<Container maxWidth="sm">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Unisono
</Typography>
<Box sx={{ mt: 3 }}>
<Button
type="button"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
onClick={handleCreate}
>
Create a New Session
</Button>
</Box>
</Box>
</Container>
);
};
export default CreateSession;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Container, Typography, Box, CircularProgress, Alert, TextField, Button } from '@mui/material';
import { useSession, Session, Participant, DesireSet, Decision, SessionState } from '../hooks/useSession';
import { webSocketService } from '../services/websocket';
import DesireForm from '../components/DesireForm';
import ResultsDisplay from '../components/ResultsDisplay';
const SessionPage = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const [session, setSession, sendMessage, clientId] = useSession(sessionId || '');
const [error, setError] = useState<string | null>(null);
const [expectedResponses, setExpectedResponses] = useState(2);
const [topic, setTopic] = useState('');
const handleSetupSession = () => {
sendMessage({ type: 'SETUP_SESSION', payload: { expectedResponses, topic } });
};
const handleSubmitDesires = (desires: { wants: string[], accepts: string[], noGoes: string[] }) => {
if (!session || !clientId) return;
const desireSet: DesireSet = {
participantId: clientId, // Use the clientId from the hook
wants: desires.wants,
accepts: desires.accepts,
noGoes: desires.noGoes,
};
sendMessage({
type: 'SUBMIT_RESPONSE',
payload: { response: desireSet },
});
};
if (!session) {
return <Typography>Loading session...</Typography>;
}
const remainingResponses = session.expectedResponses - session.submittedCount;
const hasSubmittedCurrentParticipant = session.responses && session.responses[clientId];
return (
<Container maxWidth="md">
<Box sx={{ mt: 4 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Typography variant="h4" component="h1" gutterBottom>
Session: {session.topic || session.sessionId}
</Typography>
{session.state === SessionState.SETUP && (
<Box sx={{ mt: 4, p: 3, border: '1px dashed grey', borderRadius: '4px', textAlign: 'center' }}>
<Typography variant="h6" component="p" gutterBottom>
Set Up the Session
</Typography>
<TextField
margin="normal"
fullWidth
id="topic"
label="Session Topic"
name="topic"
autoFocus
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="expectedResponses"
label="Number of Expected Responses"
type="number"
id="expectedResponses"
value={expectedResponses}
onChange={(e) => setExpectedResponses(parseInt(e.target.value, 10))}
InputProps={{
inputProps: { min: 1 }
}}
/>
<Button
type="button"
variant="contained"
sx={{ mt: 2 }}
onClick={handleSetupSession}
>
Start Session
</Button>
</Box>
)}
{session.state !== SessionState.SETUP && (
<>
<Typography variant="h6" gutterBottom>
Expected Responses: {session.expectedResponses}
</Typography>
<Typography variant="body1" gutterBottom>
Status: {session.state}
</Typography>
</>
)}
{session.state === SessionState.GATHERING && !hasSubmittedCurrentParticipant && (
<DesireForm onSubmit={handleSubmitDesires} />
)}
{session.state === SessionState.GATHERING && hasSubmittedCurrentParticipant && (
<Box sx={{ mt: 4, p: 3, border: '1px dashed grey', borderRadius: '4px', textAlign: 'center' }}>
<Typography variant="h6" component="p">
Waiting for {remainingResponses} more responses...
</Typography>
<Typography variant="body2" color="text.secondary">
Your desires have been submitted. The results will be calculated once all participants have responded.
</Typography>
</Box>
)}
{session.state === SessionState.HARMONIZING && (
<Box sx={{ mt: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<CircularProgress />
<Typography variant="h6" sx={{ mt: 2 }}>Harmonizing Desires...</Typography>
</Box>
)}
{session.state === SessionState.FINAL && session.finalResult && (
<ResultsDisplay decision={session.finalResult} />
)}
</Box>
</Container>
);
};
export default SessionPage;

View File

@@ -0,0 +1,85 @@
class WebSocketService {
private ws: WebSocket | null = null;
private messageHandlers: ((message: any) => void)[] = [];
private errorHandlers: ((error: Event) => void)[] = [];
private currentSessionId: string | null = null;
private currentClientId: string | null = null;
connect(sessionId: string, clientId: string) {
// Prevent multiple connections
if (this.ws) {
return;
}
this.currentSessionId = sessionId;
this.currentClientId = clientId;
const wsUrl = `ws://localhost:8000/sessions/${sessionId}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
// Directly send registration message on open
this.sendMessage({ type: 'REGISTER_CLIENT' });
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
console.log('WebSocketService: Received and parsed message:', message);
this.messageHandlers.forEach(handler => handler(message));
} catch (error) {
console.error('Error parsing incoming message:', error);
}
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.ws = null;
this.currentSessionId = null;
this.currentClientId = null;
};
this.ws.onerror = (event) => {
console.error('WebSocket error:', event);
this.errorHandlers.forEach(handler => handler(event));
};
}
disconnect() {
if (this.ws) {
this.ws.close();
}
}
sendMessage(message: any) {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentClientId && this.currentSessionId) {
const messageToSend = {
...message,
clientId: this.currentClientId,
sessionId: this.currentSessionId,
};
this.ws.send(JSON.stringify(messageToSend));
} else {
// This error can be ignored if it happens during initial connection in StrictMode
// console.error('WebSocket is not connected or clientId/sessionId is missing.');
}
}
onMessage(handler: (message: any) => void) {
this.messageHandlers.push(handler);
}
removeMessageHandler(handler: (message: any) => void) {
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
}
onError(handler: (error: Event) => void) {
this.errorHandlers.push(handler);
}
removeErrorHandler(handler: (error: Event) => void) {
this.errorHandlers = this.errorHandlers.filter(h => h !== handler);
}
}
export const webSocketService = new WebSocketService();

21
frontend/src/theme.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createTheme } from '@mui/material/styles';
// A basic theme for the application
const theme = createTheme({
palette: {
primary: {
main: '#6750A4', // A Material Design 3 primary color
},
secondary: {
main: '#958DA5',
},
background: {
default: '#F3F4F6', // A light grey background
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
},
});
export default theme;

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}