# Phase 7: Real-time Updates + Family Sharing **Timeline**: Week 7 **Goal**: Add real-time updates and collaborative features **Dependencies**: Phases 1-6 complete **Status**: Ready for implementation ## ๐ฏ Phase Objectives Build on Phases 1-6 by adding: - โ ActionCable integration for real-time updates - โ Real-time point updates (live location tracking) - โ Family layer (shared locations) - โ Live notifications - โ WebSocket reconnection logic - โ Presence indicators - โ E2E tests **Deploy Decision**: Full collaborative features with real-time location sharing. --- ## ๐ Features Checklist - [ ] ActionCable channel subscription - [ ] Real-time point updates - [ ] Family member locations layer - [ ] Live toast notifications - [ ] WebSocket auto-reconnect - [ ] Online/offline indicators - [ ] Family member colors - [ ] E2E tests passing --- ## ๐๏ธ New Files (Phase 7) ``` app/javascript/maps_v2/ โโโ layers/ โ โโโ family_layer.js # NEW: Family locations โโโ controllers/ โ โโโ realtime_controller.js # NEW: ActionCable โโโ channels/ โ โโโ map_channel.js # NEW: Channel consumer โโโ utils/ โโโ websocket_manager.js # NEW: Connection management app/channels/ โโโ map_channel.rb # NEW: Rails channel e2e/v2/ โโโ phase-7-realtime.spec.ts # NEW: E2E tests ``` --- ## 7.1 Family Layer Display family member locations. **File**: `app/javascript/maps_v2/layers/family_layer.js` ```javascript import { BaseLayer } from './base_layer' /** * Family layer showing family member locations * Each member has unique color */ export class FamilyLayer extends BaseLayer { constructor(map, options = {}) { super(map, { id: 'family', ...options }) this.memberColors = {} } getSourceConfig() { return { type: 'geojson', data: this.data || { type: 'FeatureCollection', features: [] } } } getLayerConfigs() { return [ // Member circles { id: this.id, type: 'circle', source: this.sourceId, paint: { 'circle-radius': 10, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.9 } }, // Member labels { id: `${this.id}-labels`, type: 'symbol', source: this.sourceId, layout: { 'text-field': ['get', 'name'], 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-offset': [0, 1.5], 'text-anchor': 'top' }, paint: { 'text-color': '#111827', 'text-halo-color': '#ffffff', 'text-halo-width': 2 } }, // Pulse animation { id: `${this.id}-pulse`, type: 'circle', source: this.sourceId, paint: { 'circle-radius': [ 'interpolate', ['linear'], ['zoom'], 10, 15, 15, 25 ], 'circle-color': ['get', 'color'], 'circle-opacity': [ 'interpolate', ['linear'], ['get', 'lastUpdate'], Date.now() - 10000, 0, Date.now(), 0.3 ] } } ] } getLayerIds() { return [this.id, `${this.id}-labels`, `${this.id}-pulse`] } /** * Update single family member location * @param {Object} member - { id, name, latitude, longitude, color } */ updateMember(member) { const features = this.data?.features || [] // Find existing or add new const index = features.findIndex(f => f.properties.id === member.id) const feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [member.longitude, member.latitude] }, properties: { id: member.id, name: member.name, color: member.color || this.getMemberColor(member.id), lastUpdate: Date.now() } } if (index >= 0) { features[index] = feature } else { features.push(feature) } this.update({ type: 'FeatureCollection', features }) } /** * Get consistent color for member */ getMemberColor(memberId) { if (!this.memberColors[memberId]) { const colors = [ '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899' ] const index = Object.keys(this.memberColors).length % colors.length this.memberColors[memberId] = colors[index] } return this.memberColors[memberId] } /** * Remove family member */ removeMember(memberId) { const features = this.data?.features || [] const filtered = features.filter(f => f.properties.id !== memberId) this.update({ type: 'FeatureCollection', features: filtered }) } } ``` --- ## 7.2 WebSocket Manager **File**: `app/javascript/maps_v2/utils/websocket_manager.js` ```javascript /** * WebSocket connection manager * Handles reconnection logic and connection state */ export class WebSocketManager { constructor(options = {}) { this.maxReconnectAttempts = options.maxReconnectAttempts || 5 this.reconnectDelay = options.reconnectDelay || 1000 this.reconnectAttempts = 0 this.isConnected = false this.subscription = null this.onConnect = options.onConnect || null this.onDisconnect = options.onDisconnect || null this.onError = options.onError || null } /** * Connect to channel * @param {Object} subscription - ActionCable subscription */ connect(subscription) { this.subscription = subscription // Monitor connection state this.subscription.connected = () => { this.isConnected = true this.reconnectAttempts = 0 this.onConnect?.() } this.subscription.disconnected = () => { this.isConnected = false this.onDisconnect?.() this.attemptReconnect() } } /** * Attempt to reconnect */ attemptReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.onError?.(new Error('Max reconnect attempts reached')) return } this.reconnectAttempts++ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`) setTimeout(() => { if (!this.isConnected) { this.subscription?.perform('reconnect') } }, delay) } /** * Disconnect */ disconnect() { if (this.subscription) { this.subscription.unsubscribe() this.subscription = null } this.isConnected = false } /** * Send message */ send(action, data = {}) { if (!this.isConnected) { console.warn('Cannot send message: not connected') return } this.subscription?.perform(action, data) } } ``` --- ## 7.3 Map Channel (Consumer) **File**: `app/javascript/maps_v2/channels/map_channel.js` ```javascript import consumer from './consumer' /** * Create map channel subscription * @param {Object} callbacks - { received, connected, disconnected } * @returns {Object} Subscription */ export function createMapChannel(callbacks = {}) { return consumer.subscriptions.create('MapChannel', { connected() { console.log('MapChannel connected') callbacks.connected?.() }, disconnected() { console.log('MapChannel disconnected') callbacks.disconnected?.() }, received(data) { console.log('MapChannel received:', data) callbacks.received?.(data) }, // Custom methods updateLocation(latitude, longitude) { this.perform('update_location', { latitude, longitude }) }, subscribeToFamily() { this.perform('subscribe_family') } }) } ``` --- ## 7.4 Real-time Controller **File**: `app/javascript/maps_v2/controllers/realtime_controller.js` ```javascript import { Controller } from '@hotwired/stimulus' import { createMapChannel } from '../channels/map_channel' import { WebSocketManager } from '../utils/websocket_manager' import { Toast } from '../components/toast' /** * Real-time controller * Manages ActionCable connection and real-time updates */ export default class extends Controller { static outlets = ['map'] static values = { enabled: { type: Boolean, default: true }, updateInterval: { type: Number, default: 30000 } // 30 seconds } connect() { if (!this.enabledValue) return this.setupChannel() this.startLocationUpdates() } disconnect() { this.stopLocationUpdates() this.wsManager?.disconnect() this.channel?.unsubscribe() } /** * Setup ActionCable channel */ setupChannel() { this.channel = createMapChannel({ connected: this.handleConnected.bind(this), disconnected: this.handleDisconnected.bind(this), received: this.handleReceived.bind(this) }) this.wsManager = new WebSocketManager({ onConnect: () => { Toast.success('Connected to real-time updates') this.updateConnectionIndicator(true) }, onDisconnect: () => { Toast.warning('Disconnected from real-time updates') this.updateConnectionIndicator(false) }, onError: (error) => { Toast.error('Failed to reconnect') } }) this.wsManager.connect(this.channel) } /** * Handle connection */ handleConnected() { // Subscribe to family updates this.channel.subscribeToFamily() } /** * Handle disconnection */ handleDisconnected() { // Will attempt reconnect via WebSocketManager } /** * Handle received data */ handleReceived(data) { switch (data.type) { case 'new_point': this.handleNewPoint(data.point) break case 'family_location': this.handleFamilyLocation(data.member) break case 'member_offline': this.handleMemberOffline(data.member_id) break } } /** * Handle new point */ handleNewPoint(point) { if (!this.hasMapOutlet) return // Add point to map const pointsLayer = this.mapOutlet.pointsLayer if (pointsLayer) { const currentData = pointsLayer.data const features = currentData.features || [] features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [point.longitude, point.latitude] }, properties: point }) pointsLayer.update({ type: 'FeatureCollection', features }) Toast.info('New location recorded') } } /** * Handle family member location update */ handleFamilyLocation(member) { if (!this.hasMapOutlet) return const familyLayer = this.mapOutlet.familyLayer if (familyLayer) { familyLayer.updateMember(member) } } /** * Handle family member going offline */ handleMemberOffline(memberId) { if (!this.hasMapOutlet) return const familyLayer = this.mapOutlet.familyLayer if (familyLayer) { familyLayer.removeMember(memberId) } } /** * Start sending location updates */ startLocationUpdates() { if (!navigator.geolocation) return this.locationInterval = setInterval(() => { navigator.geolocation.getCurrentPosition( (position) => { this.channel?.updateLocation( position.coords.latitude, position.coords.longitude ) }, (error) => { console.error('Geolocation error:', error) } ) }, this.updateIntervalValue) } /** * Stop sending location updates */ stopLocationUpdates() { if (this.locationInterval) { clearInterval(this.locationInterval) this.locationInterval = null } } /** * Update connection indicator */ updateConnectionIndicator(connected) { const indicator = document.querySelector('.connection-indicator') if (indicator) { indicator.classList.toggle('connected', connected) indicator.classList.toggle('disconnected', !connected) } } } ``` --- ## 7.5 Map Channel (Rails) **File**: `app/channels/map_channel.rb` ```ruby class MapChannel < ApplicationCable::Channel def subscribed stream_for current_user end def unsubscribed # Cleanup when channel is unsubscribed broadcast_to_family({ type: 'member_offline', member_id: current_user.id }) end def update_location(data) # Create new point point = current_user.points.create!( latitude: data['latitude'], longitude: data['longitude'], timestamp: Time.current.to_i, lonlat: "POINT(#{data['longitude']} #{data['latitude']})" ) # Broadcast to self MapChannel.broadcast_to(current_user, { type: 'new_point', point: point.as_json }) # Broadcast to family members broadcast_to_family({ type: 'family_location', member: { id: current_user.id, name: current_user.email, latitude: data['latitude'], longitude: data['longitude'] } }) end def subscribe_family # Stream family updates if current_user.family.present? current_user.family.members.each do |member| stream_for member unless member == current_user end end end private def broadcast_to_family(data) return unless current_user.family.present? current_user.family.members.each do |member| next if member == current_user MapChannel.broadcast_to(member, data) end end end ``` --- ## 7.6 Update Map Controller Add family layer and real-time integration. **File**: `app/javascript/maps_v2/controllers/map_controller.js` (add) ```javascript // Add import import { FamilyLayer } from '../layers/family_layer' // In loadMapData(), add: // Add family layer if (!this.familyLayer) { this.familyLayer = new FamilyLayer(this.map, { visible: false }) if (this.map.loaded()) { this.familyLayer.add({ type: 'FeatureCollection', features: [] }) } else { this.map.on('load', () => { this.familyLayer.add({ type: 'FeatureCollection', features: [] }) }) } } ``` --- ## 7.7 Connection Indicator Add to view template. **File**: `app/views/maps_v2/index.html.erb` (add) ```erb