A real-time, zero-config collaborative Sprint Retrospective application built with simplicity in mind.
- Features
- Tech Stack
- Quick Start
- Architecture Deep Dive
- Data Model
- Real-Time Communication
- Development Guide
- Deployment
- Troubleshooting
🚀 Zero Configuration - No login required, just create and share a link ⚡ Real-Time Collaboration - See changes instantly with Socket.io 🎯 Multiple Templates - Start/Stop/Continue, Went Well/To Improve, and more 🔄 Guided Workflow - Structured stages from Ice Breaker to Action Items 🎨 Card Clustering - Drag and drop cards to group similar themes 🗳️ Voting System - Democratic prioritization of items 📦 SQLite Database - No external database required 🐳 Docker Ready - One command deployment 📤 Export - Download results as Markdown
- Backend: Node.js, Express, Socket.io, SQLite, TypeScript
- Frontend: Next.js 14, React, TailwindCSS, TypeScript
- Infrastructure: Docker, Docker Compose
-
Clone and start:
docker-compose up -d
-
Access the application:
- Open http://localhost:3000
- Create a session and share the URL with your team
That's it! 🎉
- Node.js 20+
- npm or yarn
cd backend
npm install
cp .env.example .env
npm run devcd frontend
npm install
npm run dev-
Setup
- Host selects a template and configures settings
- Participants join via shared URL with display names
-
Ice Breaker 🎉
- Warm up activity to get everyone comfortable
- Share something fun or what made you smile
-
Reflect 🤔
- Write cards anonymously
- Cards are blurred to others to avoid bias
- Be honest and constructive
-
Group 🎯
- All cards are revealed
- Drag cards onto each other to create clusters
- Identify common themes
-
Vote 👍
- Each person gets X votes (configurable)
- Click to vote on the most important cards/clusters
- Democratic prioritization
-
Act ✅
- Create concrete action items
- Assign owners to tasks
- Export results for follow-up
- Start: What should we start doing?
- Stop: What should we stop doing?
- Continue: What should we continue doing?
- Went Well: What went well this sprint?
- To Improve: What could be improved?
- Liked: What did you like?
- Learned: What did you learn?
- Lacked: What was lacking?
- Longed For: What did you long for?
- Mad: What made you mad?
- Sad: What made you sad?
- Glad: What made you glad?
┌─────────────┐ WebSocket ┌─────────────┐
│ Browser │ ←─────────────────────────→ │ Backend │
│ (Next.js) │ Socket.IO │ (Express) │
└─────────────┘ └─────────────┘
│ │
│ │
├─ React Context (Global State) ├─ Socket Handlers
├─ Stage Components ├─ Services (Business Logic)
├─ Socket.IO Client ├─ Models (Data Access)
└─ Local Storage (Persistence) └─ SQLite Database
State Management Pattern:
SessionContext.tsxacts as the single source of truth for session state- Uses React Context API to provide global state to all components
- Socket.IO client integrated directly into the context
- State updates triggered by socket events automatically re-render components
Key Design Decisions:
- Optimistic UI Updates: Not implemented - all updates wait for server confirmation
- Reconnection Strategy: User ID stored in localStorage enables seamless reconnection
- Stage-Based Rendering: Each stage is an isolated component with specific UI/UX
- Real-Time Cursors: Cursor positions tracked and broadcast for collaborative feel
Component Hierarchy:
SessionProvider (Context)
└── SessionPage
├── StageIndicator (Shows current stage)
├── SetupStage (Waiting room)
├── IceBreakerStage (Ice breaker activity)
├── ReflectStage (Anonymous card creation)
├── GroupStage (Drag-and-drop clustering)
├── VoteStage (Dot voting)
└── ActStage (Action items)
Layered Architecture:
Socket Handlers (sockets/handlers.ts)
↓
Services (Business Logic)
├── SessionService
├── ClusterService
└── ExportService
↓
Models (Data Access Layer)
├── SessionModel
├── UserModel
├── CardModel
├── ClusterModel
├── VoteModel
├── ActionItemModel
└── IceBreakerModel
↓
Database (SQLite)
Key Patterns:
- Event-Driven Architecture: Socket.IO events trigger service methods
- Data Access Layer: Models abstract database operations
- Service Layer: Business logic separated from data access
- Socket Rooms: Each session is a Socket.IO room for efficient broadcasting
Critical Implementation Details:
-
User Persistence on Disconnect (handlers.ts:434-442):
- Users are NOT deleted on disconnect to allow reconnection
- User cleanup happens only when session is deleted
- Enables seamless page reload without data loss
-
Stage Advancement Logic (SessionService.ts:28-53):
- Linear progression through predefined stages
- Auto-reveal cards when entering GROUP stage
- Host-only operation enforced at service layer
-
Clustering Algorithm (ClusterService.ts):
- Merge two cards: Create new cluster
- Merge card into cluster: Add to existing cluster
- Merge two clusters: Combine all cards
- Maintains card relationships via
clusterIdfield
Entity Relationships:
Session (1) ──< Users (*)
Session (1) ──< Cards (*)
Session (1) ──< Clusters (*)
Session (1) ──< Votes (*)
Session (1) ──< ActionItems (*)
Session (1) ──< IceBreakers (*)
Cluster (1) ──< Cards (*) [via clusterId]
Card (1) ──< Votes (*) [polymorphic via targetId]
Cluster (1) ──< Votes (*) [polymorphic via targetId]
User (1) ──< Votes (*)
User (1) ──< Cards (*)
User (1) ──< IceBreakers (*)
Core Entities:
// Session - Main retrospective session
interface Session {
id: string;
hostId: string; // UUID of host user
name: string; // Session display name
stage: SessionStage; // Current stage enum
settings: SessionSettings; // Configuration object
timerEndAt: number | null; // Unix timestamp
iceBreakersRevealed: boolean; // Whether to show ice breaker responses
createdAt: number;
updatedAt: number;
}
// User - Session participant
interface User {
id: string;
sessionId: string;
displayName: string;
isHost: boolean; // Only one host per session
color: string; // Hex color for visual identification
cursorPosition: { x: number; y: number } | null;
joinedAt: number;
}
// Card - Retrospective card/sticky note
interface Card {
id: string;
sessionId: string;
userId: string; // Creator
category: 'wentWell' | 'toImprove' | 'actionItem';
content: string;
color: string;
position: { x: number; y: number };
clusterId: string | null; // Null if not clustered
revealed: boolean; // Hidden during REFLECT stage
createdAt: number;
updatedAt: number;
}
// Cluster - Group of related cards
interface Cluster {
id: string;
sessionId: string;
cardIds: string[]; // Array of card IDs
category: string;
title: string; // Cluster name/label
color: string;
position: { x: number; y: number };
createdAt: number;
updatedAt: number;
}
// Vote - Dot vote on card or cluster
interface Vote {
id: string;
sessionId: string;
userId: string;
targetId: string; // Card or Cluster ID
targetType: 'card' | 'cluster'; // Polymorphic relationship
createdAt: number;
}
// ActionItem - Concrete action with owner
interface ActionItem {
id: string;
sessionId: string;
owner: string; // Person responsible
task: string; // Action description
completed: boolean;
createdAt: number;
}
// IceBreaker - Ice breaker response
interface IceBreaker {
id: string;
sessionId: string;
userId: string;
content: string;
type: 'text' | 'gif' | 'drawing'; // Future: support rich media
createdAt: number;
}
// SessionStage - Linear progression
enum SessionStage {
SETUP = 'setup',
ICE_BREAKER = 'ice_breaker',
REFLECT = 'reflect',
GROUP = 'group',
VOTE = 'vote',
ACT = 'act',
COMPLETE = 'complete'
}When a card is dragged onto another:
- Neither clustered → Create new cluster
- Source clustered, target not → Add target to source cluster
- Target clustered, source not → Add source to target cluster
- Both in same cluster → No-op
- Both in different clusters → Merge clusters
Connection & Session Management:
// Client → Server: Join a session
socket.emit('join:session', {
sessionId: string,
displayName: string,
hostId?: string, // Present if user is host
userId?: string // Present if reconnecting
});
// Server → Client: Full session state sync
socket.on('session:state', {
session: Session,
users: User[],
cards: Card[],
clusters: Cluster[],
votes: Vote[],
actionItems: ActionItem[],
iceBreakers: IceBreaker[]
});
// Server → All: New user joined
socket.on('user:joined', user: User);
// Server → All: User disconnected
socket.on('user:left', { userId: string });Card Operations:
// Client → Server: Create new card
socket.emit('card:create', {
userId: string,
category: string,
content: string,
color: string,
position: { x: number, y: number },
revealed: boolean
});
// Server → All: Card created
socket.on('card:created', card: Card);
// Client → Server: Update card
socket.emit('card:update', {
id: string,
updates: Partial<Card>
});
// Server → All: Card updated
socket.on('card:updated', card: Card);
// Client → Server: Delete card
socket.emit('card:delete', { id: string });
// Server → All: Card deleted
socket.on('card:deleted', { id: string });Clustering:
// Client → Server: Merge two cards
socket.emit('cluster:create', {
sourceCardId: string,
targetCardId: string
});
// Server → All: Cluster created
socket.on('cluster:created', cluster: Cluster);
// Client → Server: Update cluster
socket.emit('cluster:update', {
id: string,
updates: Partial<Cluster>
});
// Server → All: Cluster updated
socket.on('cluster:updated', cluster: Cluster);
// Client → Server: Ungroup cluster
socket.emit('cluster:ungroup', { clusterId: string });
// Server → All: Cluster deleted
socket.on('cluster:deleted', { id: string });Voting:
// Client → Server: Cast vote
socket.emit('vote:cast', {
targetId: string,
targetType: 'card' | 'cluster'
});
// Server → All: Vote added
socket.on('vote:added', vote: Vote);
// Client → Server: Remove vote
socket.emit('vote:remove', { voteId: string });
// Server → All: Vote removed
socket.on('vote:removed', { voteId: string });Ice Breaker:
// Client → Server: Share ice breaker response
socket.emit('icebreaker:create', {
content: string,
type: 'text' | 'gif' | 'drawing'
});
// Server → All: Ice breaker created
socket.on('icebreaker:created', iceBreaker: IceBreaker);
// Client → Server: Reveal all responses (host only)
socket.emit('icebreaker:reveal');
// Server → All: Ice breakers revealed
socket.on('icebreaker:revealed', {
iceBreakersRevealed: boolean
});Session Control (Host Only):
// Client → Server: Advance to next stage
socket.emit('stage:advance');
// Server → All: Stage changed
socket.on('stage:changed', { stage: SessionStage });
// Client → Server: Start timer
socket.emit('timer:start', { duration: number });
// Server → All: Timer tick
socket.on('timer:tick', { remainingSeconds: number });
// Client → Server: Export session
socket.emit('session:export');
// Server → Client: Exported markdown
socket.on('session:exported', { markdown: string });Real-Time Cursors:
// Client → Server: Cursor moved
socket.emit('cursor:move', { x: number, y: number });
// Server → All: Cursor position updated
socket.on('cursor:updated', {
userId: string,
position: { x: number, y: number }
});Error Handling:
// Server → Client: Error occurred
socket.on('error', { message: string });- Each session has its own Socket.IO room (roomId = sessionId)
- Users join their session's room on
join:session - Broadcasts use
io.to(sessionId).emit()for efficiency - Only users in the same session receive updates
retros/
├── backend/src/
│ ├── index.ts # Express + Socket.IO server setup
│ ├── db/
│ │ ├── database.ts # SQLite connection singleton
│ │ └── schema.sql # Database schema definition
│ ├── models/ # Data Access Layer (DAL)
│ │ ├── Session.ts # CRUD for sessions table
│ │ ├── User.ts # CRUD for users table
│ │ ├── Card.ts # CRUD for cards table
│ │ ├── Cluster.ts # CRUD for clusters table
│ │ ├── Vote.ts # CRUD for votes table
│ │ ├── ActionItem.ts # CRUD for action_items table
│ │ └── IceBreaker.ts # CRUD for ice_breakers table
│ ├── services/ # Business Logic Layer
│ │ ├── SessionService.ts # Session lifecycle, stage management
│ │ ├── ClusterService.ts # Card clustering logic
│ │ └── ExportService.ts # Markdown export generation
│ └── sockets/
│ └── handlers.ts # All Socket.IO event handlers
│
├── frontend/src/
│ ├── app/ # Next.js App Router
│ │ ├── page.tsx # Home page - create session
│ │ ├── layout.tsx # Root layout with providers
│ │ └── session/[id]/page.tsx # Main session page
│ ├── components/
│ │ ├── Stages/ # Stage-specific components
│ │ │ ├── SetupStage.tsx # Waiting room
│ │ │ ├── IceBreakerStage.tsx # Ice breaker activity
│ │ │ ├── ReflectStage.tsx # Card creation
│ │ │ ├── GroupStage.tsx # Drag & drop clustering
│ │ │ ├── VoteStage.tsx # Voting interface
│ │ │ └── ActStage.tsx # Action items
│ │ └── Board/ # Shared board components
│ │ ├── Card.tsx # Draggable card
│ │ └── Cluster.tsx # Card group
│ ├── context/
│ │ └── SessionContext.tsx # Global state + Socket.IO
│ ├── lib/
│ │ └── socket.ts # Socket.IO client singleton
│ └── types/
│ └── index.ts # Frontend-specific types
│
└── shared/
└── types/
└── index.ts # Shared types between FE/BE
Backend (handlers.ts):
socket.on('my-event:create', (data: MyEventData) => {
if (!socketData.sessionId) return;
try {
// Validate data
if (!data.requiredField) {
socket.emit('error', { message: 'Missing required field' });
return;
}
// Business logic
const result = MyService.create(data);
// Broadcast to all in session
io.to(socketData.sessionId).emit('my-event:created', result);
} catch (error: any) {
socket.emit('error', { message: error.message });
}
});Frontend (SessionContext.tsx):
// 1. Add socket listener in useEffect
newSocket.on('my-event:created', (data: MyEvent) => {
setMyEvents(prev => [...prev, data]);
});
// 2. Add action function
const createMyEvent = useCallback((data: MyEventData) => {
socket?.emit('my-event:create', data);
}, [socket]);
// 3. Export from context
return (
<SessionContext.Provider value={{ createMyEvent, ... }}>Frontend (Component):
const { createMyEvent, myEvents } = useSession();
const handleCreate = () => {
createMyEvent({ requiredField: 'value' });
};1. Update schema.sql:
CREATE TABLE IF NOT EXISTS my_table (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
some_field TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX idx_my_table_session ON my_table(session_id);2. Create Model (models/MyTable.ts):
import { getDatabase } from '../db/database';
import { MyTable } from '../../../shared/types';
export class MyTableModel {
static create(item: MyTable): MyTable {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO my_table (id, session_id, some_field, created_at)
VALUES (?, ?, ?, ?)
`);
stmt.run(item.id, item.sessionId, item.someField, item.createdAt);
return item;
}
static findById(id: string): MyTable | null {
const db = getDatabase();
const stmt = db.prepare('SELECT * FROM my_table WHERE id = ?');
const row = stmt.get(id) as any;
if (!row) return null;
return {
id: row.id,
sessionId: row.session_id,
someField: row.some_field,
createdAt: row.created_at
};
}
static findBySessionId(sessionId: string): MyTable[] {
const db = getDatabase();
const stmt = db.prepare('SELECT * FROM my_table WHERE session_id = ?');
const rows = stmt.all(sessionId) as any[];
return rows.map(row => ({
id: row.id,
sessionId: row.session_id,
someField: row.some_field,
createdAt: row.created_at
}));
}
static update(id: string, updates: Partial<MyTable>): void {
const db = getDatabase();
const fields: string[] = [];
const values: any[] = [];
if (updates.someField !== undefined) {
fields.push('some_field = ?');
values.push(updates.someField);
}
values.push(id);
const stmt = db.prepare(`UPDATE my_table SET ${fields.join(', ')} WHERE id = ?`);
stmt.run(...values);
}
static delete(id: string): void {
const db = getDatabase();
const stmt = db.prepare('DELETE FROM my_table WHERE id = ?');
stmt.run(id);
}
}3. Add TypeScript interface (shared/types/index.ts):
export interface MyTable {
id: string;
sessionId: string;
someField: string;
createdAt: number;
}4. Rebuild containers:
docker-compose down -v # Deletes database
docker-compose up -d --build1. Add to SessionStage enum (shared/types/index.ts):
export enum SessionStage {
SETUP = 'setup',
ICE_BREAKER = 'ice_breaker',
MY_NEW_STAGE = 'my_new_stage', // Add here
REFLECT = 'reflect',
// ...
}2. Update stage order (SessionService.ts):
const stages = [
SessionStage.SETUP,
SessionStage.ICE_BREAKER,
SessionStage.MY_NEW_STAGE, // Add here
SessionStage.REFLECT,
// ...
];3. Create component (frontend/src/components/Stages/MyNewStage.tsx):
'use client';
import { useSession } from '../../context/SessionContext';
export default function MyNewStage() {
const { session, users, currentUser, isHost } = useSession();
return (
<div className="max-w-6xl mx-auto">
<h2 className="text-2xl font-bold mb-4">My New Stage</h2>
{/* Your stage UI */}
</div>
);
}4. Import and render (frontend/src/app/session/[id]/page.tsx):
import MyNewStage from '../../../components/Stages/MyNewStage';
// In render:
{session.stage === SessionStage.MY_NEW_STAGE && <MyNewStage />}Backend logging:
socket.on('my-event', (data) => {
console.log('📥 Received my-event:', data);
console.log('Socket ID:', socket.id);
console.log('Session ID:', socketData.sessionId);
console.log('User ID:', socketData.userId);
// Process...
console.log('📤 Emitting my-event:created');
io.to(socketData.sessionId).emit('my-event:created', result);
console.log('✅ Event emitted successfully');
});Frontend logging:
useEffect(() => {
console.log('🔌 Setting up socket listeners');
newSocket.on('my-event:created', (data) => {
console.log('📥 Received my-event:created:', data);
setMyEvents(prev => [...prev, data]);
});
return () => {
console.log('🔌 Cleaning up socket listeners');
newSocket.off('my-event:created');
};
}, []);Check socket connection:
const { socket, connected } = useSession();
console.log('Socket connected:', connected);
console.log('Socket ID:', socket?.id);How it works:
- On session join, user ID stored in localStorage:
user_${sessionId} - On page reload, user ID is sent with join request
- Backend checks if user ID exists and matches session
- If match, returns existing user instead of creating new one
- User maintains same ID, votes, and card ownership
Implementation (frontend/src/app/session/[id]/page.tsx):
const handleJoin = () => {
const hostId = localStorage.getItem(`host_${sessionId}`);
const userId = localStorage.getItem(`user_${sessionId}`);
joinSession(sessionId, displayName);
// joinSession internally passes userId to backend
};Implementation (backend/src/services/SessionService.ts:71-82):
if (userId) {
const existingUser = UserModel.findById(userId);
if (existingUser && existingUser.sessionId === sessionId) {
console.log('Reconnecting existing user:', existingUser.displayName);
return { user: existingUser, session };
}
}Backend enforcement (handlers.ts:334-352):
socket.on('stage:advance', () => {
if (!socketData.userId || !socketData.sessionId) return;
const user = UserModel.findById(socketData.userId);
if (!user || !user.isHost) {
socket.emit('error', { message: 'Only host can advance stage' });
return;
}
// Proceed with operation
});Frontend UI (component):
const { isHost } = useSession();
{isHost && (
<button onClick={advanceStage}>
Next Stage
</button>
)}Current implementation waits for server confirmation. To add optimistic updates:
const createCard = useCallback((data: CardData) => {
// Optimistic update
const tempId = `temp-${Date.now()}`;
const tempCard = { ...data, id: tempId };
setCards(prev => [...prev, tempCard]);
// Send to server
socket?.emit('card:create', data);
// Server response replaces temp card
socket?.once('card:created', (serverCard) => {
setCards(prev => prev.map(c => c.id === tempId ? serverCard : c));
});
}, [socket]);-
Start fresh containers:
docker-compose down -v docker-compose up -d --build
-
Open multiple browser windows:
- Window 1: Create session as host
- Window 2 (incognito): Join as participant
-
Test each stage:
- Verify real-time updates appear in both windows
- Check console for errors
- Monitor backend logs:
docker-compose logs -f backend
-
Test reconnection:
- Reload page in Window 2
- Verify user maintains identity and data
cd backend
npm test # If tests are configuredManual API testing:
# Health check
curl http://localhost:4000/health
# Create session
curl -X POST http://localhost:4000/api/sessions \
-H "Content-Type: application/json" \
-d '{"name":"Test Session"}'# Backend
PORT=3001
DATABASE_PATH=/app/data/retro.db
CORS_ORIGIN=http://localhost:3000
SESSION_CLEANUP_INTERVAL=3600000 # 1 hour
MAX_SESSION_AGE=86400000 # 24 hours
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_SOCKET_URL=http://localhost:3001retro-app/
├── backend/
│ ├── src/
│ │ ├── db/ # Database setup and schema
│ │ ├── models/ # Data models
│ │ ├── services/ # Business logic
│ │ ├── sockets/ # Socket.io handlers
│ │ └── index.ts # Server entry point
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── app/ # Next.js pages
│ │ ├── components/ # React components
│ │ ├── context/ # React context (session state)
│ │ ├── lib/ # Utilities and API client
│ │ └── types/ # TypeScript types
│ └── Dockerfile
├── shared/
│ └── types/ # Shared TypeScript types
└── docker-compose.yml
POST /api/sessions- Create new sessionGET /api/sessions/:id- Get session infoGET /health- Health check
See Architecture section for real-time events.
SQLite database with the following tables:
sessions- Retrospective sessionsusers- Session participantscards- Retro cardsclusters- Card groupingsvotes- User votesaction_items- Action items with ownersice_breakers- Ice breaker activities
docker-compose up -dFor production with multiple instances:
- Use Redis adapter for Socket.io
- Use PostgreSQL instead of SQLite
- Add load balancer (nginx)
- Enable HTTPS with Let's Encrypt
# Change ports in docker-compose.yml
ports:
- "3002:3001" # Backend
- "3001:3000" # Frontend# Reset database
docker-compose down -v
docker-compose up -d# Clean rebuild
docker-compose down
docker-compose build --no-cache
docker-compose up -d- Fork the repository
- Create a feature branch
- Make your changes
- Test thoroughly
- Submit a pull request
MIT License - Feel free to use this for your team's retrospectives!
For issues or questions:
- Open an issue on GitHub
- Check existing issues for solutions
- Anonymous mode toggle
- Custom templates
- PDF export
- Session history
- Mobile app
- Multi-language support
- Drawing/sketching on ice breaker
- GIF picker integration
- Session persistence options
Built with ❤️ for better retrospectives