BACKEND: - Fix JWT authentication in FamiliesGateway * Configure JwtModule with ConfigService in FamiliesModule * Load JWT_SECRET from environment variables * Enable proper token verification for WebSocket connections - Fix circular dependency in TrackingModule * Use forwardRef pattern for FamiliesGateway injection * Make FamiliesGateway optional in TrackingService * Emit WebSocket events when activities are created/updated/deleted FRONTEND: - Create WebSocket service (336 lines) * Socket.IO client with auto-reconnection (exponential backoff 1s → 30s) * Family room join/leave management * Presence tracking (online users per family) * Event handlers for activities, children, members * Connection recovery with auto-rejoin - Create useWebSocket hook (187 lines) * Auto-connect on user authentication * Auto-join user's family room * Connection status tracking * Presence indicators * Hooks: useRealTimeActivities, useRealTimeChildren, useRealTimeFamilyMembers - Expose access token in AuthContext * Add token property to AuthContextType interface * Load token from tokenStorage on initialization * Update token state on login/register/logout * Enable WebSocket authentication - Integrate real-time sync across app * AppShell: Connection status indicator + online count badge * Activities page: Auto-refresh on family activity events * Home page: Auto-refresh daily summary on activity changes * Family page: Real-time member updates - Fix accessibility issues * Remove deprecated legacyBehavior from Link components (Next.js 15) * Fix color contrast in EmailVerificationBanner (WCAG AA) * Add missing aria-labels to IconButtons * Fix React key warnings in family member list DOCUMENTATION: - Update implementation-gaps.md * Mark Real-Time Sync as COMPLETED ✅ * Document WebSocket room management implementation * Document connection recovery and presence indicators * Update summary statistics (49 features completed) FILES CREATED: - maternal-web/hooks/useWebSocket.ts (187 lines) - maternal-web/lib/websocket.ts (336 lines) FILES MODIFIED (14): Backend (4): - families.gateway.ts (JWT verification fix) - families.module.ts (JWT config with ConfigService) - tracking.module.ts (forwardRef for FamiliesModule) - tracking.service.ts (emit WebSocket events) Frontend (9): - lib/auth/AuthContext.tsx (expose access token) - components/layouts/AppShell/AppShell.tsx (connection status + presence) - app/activities/page.tsx (real-time activity updates) - app/page.tsx (real-time daily summary refresh) - app/family/page.tsx (accessibility fixes) - app/(auth)/login/page.tsx (remove legacyBehavior) - components/common/EmailVerificationBanner.tsx (color contrast fix) Documentation (1): - docs/implementation-gaps.md (updated status) IMPACT: ✅ Real-time family collaboration achieved ✅ Activities sync instantly across all family members' devices ✅ Presence tracking shows who's online ✅ Connection recovery handles poor network conditions ✅ Accessibility improvements (WCAG AA compliance) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
337 lines
9.2 KiB
TypeScript
337 lines
9.2 KiB
TypeScript
import { io, Socket } from 'socket.io-client';
|
|
|
|
/**
|
|
* WebSocket Client for Real-Time Family Sync
|
|
*
|
|
* Features:
|
|
* - Automatic connection/reconnection with exponential backoff
|
|
* - Family room management
|
|
* - Presence indicators
|
|
* - Activity updates (create/update/delete)
|
|
* - Connection status monitoring
|
|
*/
|
|
|
|
export interface WebSocketConfig {
|
|
url: string;
|
|
token: string;
|
|
}
|
|
|
|
export interface PresenceUpdate {
|
|
onlineUsers: string[];
|
|
count: number;
|
|
}
|
|
|
|
export type WebSocketEventCallback<T = any> = (data: T) => void;
|
|
|
|
class WebSocketService {
|
|
private socket: Socket | null = null;
|
|
private config: WebSocketConfig | null = null;
|
|
private reconnectAttempts = 0;
|
|
private maxReconnectAttempts = 10;
|
|
private reconnectDelay = 1000; // Start with 1 second
|
|
private maxReconnectDelay = 30000; // Max 30 seconds
|
|
private currentFamilyId: string | null = null;
|
|
private eventListeners: Map<string, Set<WebSocketEventCallback>> = new Map();
|
|
private connectionStatusListeners: Set<(connected: boolean) => void> = new Set();
|
|
|
|
constructor() {}
|
|
|
|
/**
|
|
* Connect to WebSocket server
|
|
*/
|
|
connect(config: WebSocketConfig): void {
|
|
if (this.socket?.connected) {
|
|
console.log('[WebSocket] Already connected');
|
|
return;
|
|
}
|
|
|
|
console.log('[WebSocket] Connecting to:', config.url);
|
|
console.log('[WebSocket] Token length:', config.token?.length || 0);
|
|
this.config = config;
|
|
|
|
this.socket = io(config.url, {
|
|
auth: {
|
|
token: config.token,
|
|
},
|
|
transports: ['websocket', 'polling'],
|
|
reconnection: true,
|
|
reconnectionAttempts: this.maxReconnectAttempts,
|
|
reconnectionDelay: this.reconnectDelay,
|
|
reconnectionDelayMax: this.maxReconnectDelay,
|
|
secure: config.url.startsWith('https'),
|
|
rejectUnauthorized: false, // For development with self-signed certs
|
|
path: '/socket.io/', // Explicit path for Socket.IO
|
|
withCredentials: true,
|
|
extraHeaders: {
|
|
'Authorization': `Bearer ${config.token}`,
|
|
},
|
|
});
|
|
|
|
this.setupEventHandlers();
|
|
}
|
|
|
|
/**
|
|
* Disconnect from WebSocket server
|
|
*/
|
|
disconnect(): void {
|
|
if (this.socket) {
|
|
console.log('[WebSocket] Disconnecting...');
|
|
this.socket.disconnect();
|
|
this.socket = null;
|
|
this.currentFamilyId = null;
|
|
this.notifyConnectionStatus(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Join a family room to receive real-time updates
|
|
*/
|
|
joinFamily(familyId: string): void {
|
|
if (!this.socket?.connected) {
|
|
console.warn('[WebSocket] Not connected, cannot join family');
|
|
return;
|
|
}
|
|
|
|
console.log('[WebSocket] Joining family room:', familyId);
|
|
this.currentFamilyId = familyId;
|
|
this.socket.emit('joinFamily', { familyId });
|
|
}
|
|
|
|
/**
|
|
* Leave current family room
|
|
*/
|
|
leaveFamily(): void {
|
|
if (!this.socket?.connected || !this.currentFamilyId) {
|
|
return;
|
|
}
|
|
|
|
console.log('[WebSocket] Leaving family room:', this.currentFamilyId);
|
|
this.socket.emit('leaveFamily');
|
|
this.currentFamilyId = null;
|
|
}
|
|
|
|
/**
|
|
* Add event listener
|
|
*/
|
|
on<T = any>(event: string, callback: WebSocketEventCallback<T>): () => void {
|
|
if (!this.eventListeners.has(event)) {
|
|
this.eventListeners.set(event, new Set());
|
|
}
|
|
this.eventListeners.get(event)!.add(callback);
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
this.off(event, callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove event listener
|
|
*/
|
|
off(event: string, callback: WebSocketEventCallback): void {
|
|
const listeners = this.eventListeners.get(event);
|
|
if (listeners) {
|
|
listeners.delete(callback);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add connection status listener
|
|
*/
|
|
onConnectionStatusChange(callback: (connected: boolean) => void): () => void {
|
|
this.connectionStatusListeners.add(callback);
|
|
// Immediately call with current status
|
|
callback(this.isConnected());
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
this.connectionStatusListeners.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if connected
|
|
*/
|
|
isConnected(): boolean {
|
|
return this.socket?.connected || false;
|
|
}
|
|
|
|
/**
|
|
* Get current family ID
|
|
*/
|
|
getCurrentFamilyId(): string | null {
|
|
return this.currentFamilyId;
|
|
}
|
|
|
|
/**
|
|
* Setup socket event handlers
|
|
*/
|
|
private setupEventHandlers(): void {
|
|
if (!this.socket) return;
|
|
|
|
// Connection events
|
|
this.socket.on('connect', () => {
|
|
console.log('[WebSocket] Connected');
|
|
this.reconnectAttempts = 0;
|
|
this.reconnectDelay = 1000;
|
|
this.notifyConnectionStatus(true);
|
|
|
|
// Rejoin family if we were in one
|
|
if (this.currentFamilyId) {
|
|
console.log('[WebSocket] Rejoining family after reconnect:', this.currentFamilyId);
|
|
this.socket!.emit('joinFamily', { familyId: this.currentFamilyId });
|
|
}
|
|
});
|
|
|
|
this.socket.on('disconnect', (reason) => {
|
|
console.log('[WebSocket] Disconnected:', reason);
|
|
this.notifyConnectionStatus(false);
|
|
|
|
// Attempt reconnection with exponential backoff
|
|
if (reason === 'io server disconnect') {
|
|
// Server initiated disconnect, try to reconnect
|
|
this.attemptReconnect();
|
|
}
|
|
});
|
|
|
|
this.socket.on('connect_error', (error) => {
|
|
console.error('[WebSocket] Connection error:', error.message);
|
|
console.error('[WebSocket] Error details:', error);
|
|
console.error('[WebSocket] Connection URL:', this.config?.url);
|
|
this.attemptReconnect();
|
|
});
|
|
|
|
this.socket.on('error', (error) => {
|
|
console.error('[WebSocket] Error:', error);
|
|
this.emitToListeners('error', error);
|
|
});
|
|
|
|
// Custom events
|
|
this.socket.on('connected', (data) => {
|
|
console.log('[WebSocket] Server acknowledged connection:', data);
|
|
});
|
|
|
|
this.socket.on('familyJoined', (data) => {
|
|
console.log('[WebSocket] Family joined:', data);
|
|
this.emitToListeners('familyJoined', data);
|
|
});
|
|
|
|
this.socket.on('familyLeft', (data) => {
|
|
console.log('[WebSocket] Family left:', data);
|
|
this.emitToListeners('familyLeft', data);
|
|
});
|
|
|
|
// Presence updates
|
|
this.socket.on('presenceUpdate', (data: PresenceUpdate) => {
|
|
console.log('[WebSocket] Presence update:', data);
|
|
this.emitToListeners('presenceUpdate', data);
|
|
});
|
|
|
|
// Activity events
|
|
this.socket.on('activityCreated', (activity) => {
|
|
console.log('[WebSocket] Activity created:', activity);
|
|
this.emitToListeners('activityCreated', activity);
|
|
});
|
|
|
|
this.socket.on('activityUpdated', (activity) => {
|
|
console.log('[WebSocket] Activity updated:', activity);
|
|
this.emitToListeners('activityUpdated', activity);
|
|
});
|
|
|
|
this.socket.on('activityDeleted', (data) => {
|
|
console.log('[WebSocket] Activity deleted:', data);
|
|
this.emitToListeners('activityDeleted', data);
|
|
});
|
|
|
|
// Child events
|
|
this.socket.on('childAdded', (child) => {
|
|
console.log('[WebSocket] Child added:', child);
|
|
this.emitToListeners('childAdded', child);
|
|
});
|
|
|
|
this.socket.on('childUpdated', (child) => {
|
|
console.log('[WebSocket] Child updated:', child);
|
|
this.emitToListeners('childUpdated', child);
|
|
});
|
|
|
|
this.socket.on('childDeleted', (data) => {
|
|
console.log('[WebSocket] Child deleted:', data);
|
|
this.emitToListeners('childDeleted', data);
|
|
});
|
|
|
|
// Member events
|
|
this.socket.on('memberAdded', (member) => {
|
|
console.log('[WebSocket] Member added:', member);
|
|
this.emitToListeners('memberAdded', member);
|
|
});
|
|
|
|
this.socket.on('memberUpdated', (member) => {
|
|
console.log('[WebSocket] Member updated:', member);
|
|
this.emitToListeners('memberUpdated', member);
|
|
});
|
|
|
|
this.socket.on('memberRemoved', (data) => {
|
|
console.log('[WebSocket] Member removed:', data);
|
|
this.emitToListeners('memberRemoved', data);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attempt reconnection with exponential backoff
|
|
*/
|
|
private attemptReconnect(): void {
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
console.error('[WebSocket] Max reconnection attempts reached');
|
|
return;
|
|
}
|
|
|
|
this.reconnectAttempts++;
|
|
const delay = Math.min(
|
|
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
|
this.maxReconnectDelay
|
|
);
|
|
|
|
console.log(
|
|
`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`
|
|
);
|
|
|
|
setTimeout(() => {
|
|
if (this.config && !this.socket?.connected) {
|
|
this.connect(this.config);
|
|
}
|
|
}, delay);
|
|
}
|
|
|
|
/**
|
|
* Emit event to all registered listeners
|
|
*/
|
|
private emitToListeners<T = any>(event: string, data: T): void {
|
|
const listeners = this.eventListeners.get(event);
|
|
if (listeners) {
|
|
listeners.forEach((callback) => {
|
|
try {
|
|
callback(data);
|
|
} catch (error) {
|
|
console.error(`[WebSocket] Error in event listener for '${event}':`, error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Notify connection status listeners
|
|
*/
|
|
private notifyConnectionStatus(connected: boolean): void {
|
|
this.connectionStatusListeners.forEach((callback) => {
|
|
try {
|
|
callback(connected);
|
|
} catch (error) {
|
|
console.error('[WebSocket] Error in connection status listener:', error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const websocketService = new WebSocketService();
|