410 lines
12 KiB
Markdown

# 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
<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:**
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)