# GPS Tracking System ## Overview The GPS tracking system provides real-time volunteer location monitoring with breadcrumb trail recording, distance calculation, and route visualization. It integrates with canvassing sessions for field organizing oversight and volunteer safety. **Key Capabilities:** - **Live Tracking**: Real-time volunteer GPS positions - **Breadcrumb Trails**: Auto-record GPS points every 10 seconds - **Distance Calculation**: Haversine formula for accurate walking distance - **Event Markers**: Mark key events (session start, visits, session end) - **Route Visualization**: Leaflet polyline with color-coded event markers - **1:1 Canvass Link**: Each TrackingSession linked to one CanvassSession - **Admin Oversight**: View live volunteer positions on map - **Privacy Controls**: Tracking only during active canvass sessions ## Architecture ```mermaid graph TD A[Volunteer GPS] -->|watchPosition| B[GPSTracker Component] B -->|Buffer Points| C[Local Storage] C -->|Submit Every 10s| D[POST /api/map/tracking/sessions/:id/points] D -->|Batch Insert| E[Tracking Service] E -->|Save Points| F[(TrackPoint Model)] E -->|Calculate Distance| G[Haversine Formula] G -->|Update Session| H[(TrackingSession Model)] I[Canvass Session] -->|Start| J[Canvass Service] J -->|Create 1:1| E E -->|Create| H K[Admin] -->|View Live Map| L[CanvassDashboardPage] L -->|GET /api/map/tracking/admin/live| E E -->|Query Active| H E -->|Return Positions| L M[Volunteer] -->|View Route History| N[MyRoutesPage] N -->|GET /api/map/tracking/sessions/:id/route| E E -->|Query Points| F E -->|Generate Polyline| N H -->|1:1| I H -->|1:N| F style H fill:#e1f5ff style F fill:#e1f5ff ``` **Flow Description:** 1. **Canvass session starts** → Create TrackingSession linked 1:1 2. **GPS auto-tracking** → watchPosition submits points every 10s 3. **Distance calculation** → Haversine formula calculates incremental distance 4. **Event markers** → Mark visits, session start/end with eventType 5. **Admin oversight** → View live volunteer positions on dashboard 6. **Route history** → Generate polyline from saved TrackPoints ## Database Models ### TrackingSession Model See [TrackingSession Model Documentation](../../database/models/canvass.md#trackingsession-model). **Key Fields:** - `userId`: Foreign key to volunteer User - `canvassSessionId`: 1:1 foreign key to CanvassSession - `startedAt`: Tracking start timestamp - `endedAt`: Tracking end timestamp (null while active) - `isActive`: Boolean - tracking currently running - `totalPoints`: Count of TrackPoint records - `totalDistanceM`: Total distance walked in meters - `lastLatitude` / `lastLongitude`: Most recent GPS position - `lastRecordedAt`: Timestamp of last GPS point ### TrackPoint Model See [TrackPoint Model Documentation](../../database/models/canvass.md#trackpoint-model). **Key Fields:** - `trackingSessionId`: Foreign key to TrackingSession - `latitude` / `longitude`: GPS coordinates (Decimal type) - `accuracy`: GPS accuracy in meters (lower = better) - `recordedAt`: When point was recorded (client timestamp) - `eventType`: Optional event marker (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED) **Event Type Enum:** ```typescript enum TrackPointEventType { LOCATION_ADDED // Regular GPS breadcrumb VISIT_RECORDED // Canvass visit recorded SESSION_STARTED // Canvass session started SESSION_ENDED // Canvass session ended } ``` ## API Endpoints See [Tracking Backend Module Documentation](../../backend/modules/map/tracking.md). **Volunteer Endpoints:** | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | POST | `/api/map/tracking/sessions` | Any logged-in user | Start tracking session | | PATCH | `/api/map/tracking/sessions/:id/end` | Any logged-in user | End tracking session | | POST | `/api/map/tracking/sessions/:id/points` | Any logged-in user | Submit batch of GPS points | | GET | `/api/map/tracking/sessions/:id` | Any logged-in user | Get tracking session details | | GET | `/api/map/tracking/sessions/:id/route` | Any logged-in user | Get route polyline (all points) | **Admin Endpoints:** | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | GET | `/api/map/tracking/admin/live` | MAP_ADMIN | Get live volunteer positions | | GET | `/api/map/tracking/admin/sessions/:id` | MAP_ADMIN | Get volunteer tracking session | | GET | `/api/map/tracking/admin/sessions/:id/route` | MAP_ADMIN | Get volunteer route | ## Configuration ### GPS Tracking Settings | Setting | Default | Description | |---------|---------|-------------| | `SUBMIT_INTERVAL_MS` | `10000` | Submit GPS points every 10 seconds | | `MAX_DISTANCE_JUMP_M` | `1000` | Ignore GPS glitches >1km distance | | `HIGH_ACCURACY` | `true` | Use GPS + WiFi + cellular (vs WiFi only) | | `MAX_AGE_MS` | `0` | Don't use cached GPS position | | `TIMEOUT_MS` | `10000` | GPS position timeout (10s) | ### Privacy & Security - **Opt-In Only**: Tracking only enabled when volunteer starts canvass session - **Session-Based**: Tracking ends when session ends (not continuous) - **Admin-Only**: Only MAP_ADMIN can view live positions - **Data Retention**: TrackPoints retained for analytics (consider GDPR compliance for EU campaigns) ## Code Examples ### Start Tracking Session (Backend) ```typescript // api/src/modules/map/tracking/tracking.service.ts async startSession(userId: string, data: StartTrackingInput) { const { canvassSessionId, latitude, longitude } = data; // Check for existing active session const existing = await prisma.trackingSession.findFirst({ where: { userId, isActive: true }, }); if (existing) return existing; // Reuse existing session return prisma.trackingSession.create({ data: { userId, canvassSessionId: canvassSessionId ?? null, lastLatitude: latitude != null ? new Prisma.Decimal(latitude) : null, lastLongitude: longitude != null ? new Prisma.Decimal(longitude) : null, lastRecordedAt: latitude != null ? new Date() : null, }, }); } ``` ### Submit GPS Points (Backend) ```typescript // api/src/modules/map/tracking/tracking.service.ts const MAX_DISTANCE_JUMP_M = 1000; async submitPoints(sessionId: string, userId: string, data: SubmitPointsInput) { const session = await prisma.trackingSession.findFirst({ where: { id: sessionId, userId, isActive: true }, }); if (!session) { throw new AppError(404, 'Active tracking session not found', 'SESSION_NOT_FOUND'); } const { points } = data; // Batch insert all points await prisma.trackPoint.createMany({ data: points.map((p) => ({ trackingSessionId: sessionId, latitude: new Prisma.Decimal(p.latitude), longitude: new Prisma.Decimal(p.longitude), accuracy: p.accuracy ?? null, recordedAt: new Date(p.recordedAt), eventType: p.eventType ?? null, })), }); // Calculate incremental distance let addedDistance = 0; let prevLat = session.lastLatitude ? Number(session.lastLatitude) : null; let prevLng = session.lastLongitude ? Number(session.lastLongitude) : null; const sorted = [...points].sort( (a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime() ); for (const p of sorted) { if (prevLat != null && prevLng != null) { const d = haversineDistance(prevLat, prevLng, p.latitude, p.longitude); if (d <= MAX_DISTANCE_JUMP_M) { addedDistance += d; } } prevLat = p.latitude; prevLng = p.longitude; } const lastPoint = sorted[sorted.length - 1]!; // Update session summary await prisma.trackingSession.update({ where: { id: sessionId }, data: { totalPoints: { increment: points.length }, totalDistanceM: { increment: addedDistance }, lastLatitude: new Prisma.Decimal(lastPoint.latitude), lastLongitude: new Prisma.Decimal(lastPoint.longitude), lastRecordedAt: new Date(lastPoint.recordedAt), }, }); return { accepted: points.length, distance: addedDistance }; } ``` ### GPS Auto-Tracking (Frontend) ```typescript // admin/src/components/canvass/GPSTracker.tsx useEffect(() => { if (!trackingSessionId || !enabled) return; const pointsBuffer: TrackPoint[] = []; const watchId = navigator.geolocation.watchPosition( (position) => { const point = { latitude: position.coords.latitude, longitude: position.coords.longitude, accuracy: position.coords.accuracy, recordedAt: new Date().toISOString(), }; pointsBuffer.push(point); setCurrentPosition([point.latitude, point.longitude]); }, (error) => { console.error('GPS error:', error); message.error('GPS tracking failed'); }, { enableHighAccuracy: true, maximumAge: 0, timeout: 10000, } ); // Submit buffered points every 10 seconds const interval = setInterval(async () => { if (pointsBuffer.length === 0) return; try { await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, { points: pointsBuffer.splice(0), // Drain buffer }); } catch (error) { console.error('Failed to submit GPS points:', error); } }, 10000); return () => { navigator.geolocation.clearWatch(watchId); clearInterval(interval); }; }, [trackingSessionId, enabled]); ``` ### Route Visualization (Frontend) ```typescript // admin/src/pages/volunteer/MyRoutesPage.tsx const fetchRoute = async (sessionId: string) => { const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`); // Convert TrackPoints to polyline coordinates const polyline = data.points.map((p: TrackPoint) => [p.latitude, p.longitude]); // Extract event markers const events = data.points .filter((p: TrackPoint) => p.eventType) .map((p: TrackPoint) => ({ position: [p.latitude, p.longitude], eventType: p.eventType, recordedAt: p.recordedAt, })); setRoute({ polyline, events, distance: data.totalDistanceM }); }; // Render route {route.events.map((event, i) => ( {event.eventType} - {dayjs(event.recordedAt).format('HH:mm')} ))} ``` ## Troubleshooting ### Issue: GPS Tracking Draining Battery **Solutions:** 1. Reduce accuracy: `enableHighAccuracy: false` 2. Increase submit interval: `SUBMIT_INTERVAL_MS = 30000` (30s) 3. Add pause/resume tracking buttons ### Issue: Distance Calculation Incorrect **Symptoms:** Total distance much higher than expected **Causes:** GPS glitches causing large jumps **Solutions:** Increase `MAX_DISTANCE_JUMP_M` threshold to ignore outliers: ```typescript const MAX_DISTANCE_JUMP_M = 2000; // Was 1000, increase to 2000 ``` ### Issue: Route Polyline Jagged **Symptoms:** Route looks zigzag instead of smooth **Causes:** GPS accuracy poor (±20m) **Solutions:** Apply smoothing algorithm to polyline: ```typescript import { simplify } from '@turf/turf'; const smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true }); ``` ## Performance Considerations ### Batch Point Insertion **Efficient Bulk Insert:** ```typescript // Insert all points in single transaction await prisma.trackPoint.createMany({ data: points.map((p) => ({ ... })), }); // Avoid N+1: single UPDATE instead of N UPDATEs await prisma.trackingSession.update({ where: { id: sessionId }, data: { totalPoints: { increment: points.length }, totalDistanceM: { increment: totalDistance }, }, }); ``` ### Query Optimization **Index for Route Queries:** ```sql CREATE INDEX idx_track_points_session_time ON "TrackPoint" ("trackingSessionId", "recordedAt"); ``` **Efficient Route Query:** ```typescript const points = await prisma.trackPoint.findMany({ where: { trackingSessionId: sessionId }, orderBy: { recordedAt: 'asc' }, select: { latitude: true, longitude: true, recordedAt: true, eventType: true }, }); ``` ## Related Documentation - [Canvassing](./canvassing.md) — Canvass session integration - [Tracking Backend Module](../../backend/modules/map/tracking.md) - [MyRoutesPage](../../frontend/pages/volunteer/my-routes-page.md) - [TrackingSession Model](../../database/models/canvass.md#trackingsession-model)