12 KiB
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
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:
- Canvass session starts → Create TrackingSession linked 1:1
- GPS auto-tracking → watchPosition submits points every 10s
- Distance calculation → Haversine formula calculates incremental distance
- Event markers → Mark visits, session start/end with eventType
- Admin oversight → View live volunteer positions on dashboard
- Route history → Generate polyline from saved TrackPoints
Database Models
TrackingSession Model
See TrackingSession Model Documentation.
Key Fields:
userId: Foreign key to volunteer UsercanvassSessionId: 1:1 foreign key to CanvassSessionstartedAt: Tracking start timestampendedAt: Tracking end timestamp (null while active)isActive: Boolean - tracking currently runningtotalPoints: Count of TrackPoint recordstotalDistanceM: Total distance walked in meterslastLatitude/lastLongitude: Most recent GPS positionlastRecordedAt: Timestamp of last GPS point
TrackPoint Model
See TrackPoint Model Documentation.
Key Fields:
trackingSessionId: Foreign key to TrackingSessionlatitude/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:
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.
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)
// 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)
// 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)
// 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)
// 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
<Polyline positions={route.polyline} pathOptions={{ color: '#3498db', weight: 3 }} />
{route.events.map((event, i) => (
<Marker
key={i}
position={event.position}
icon={getEventIcon(event.eventType)}
>
<Popup>{event.eventType} - {dayjs(event.recordedAt).format('HH:mm')}</Popup>
</Marker>
))}
Troubleshooting
Issue: GPS Tracking Draining Battery
Solutions:
- Reduce accuracy:
enableHighAccuracy: false - Increase submit interval:
SUBMIT_INTERVAL_MS = 30000(30s) - 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:
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:
import { simplify } from '@turf/turf';
const smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true });
Performance Considerations
Batch Point Insertion
Efficient Bulk Insert:
// 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:
CREATE INDEX idx_track_points_session_time ON "TrackPoint" ("trackingSessionId", "recordedAt");
Efficient Route Query:
const points = await prisma.trackPoint.findMany({
where: { trackingSessionId: sessionId },
orderBy: { recordedAt: 'asc' },
select: { latitude: true, longitude: true, recordedAt: true, eventType: true },
});
Related Documentation
- Canvassing — Canvass session integration
- Tracking Backend Module
- MyRoutesPage
- TrackingSession Model