Tonne of debugging - getting ready for the production builds

This commit is contained in:
2026-02-16 10:44:18 -07:00
parent a77306fac2
commit 7895ce683e
1367 changed files with 404191 additions and 2005 deletions

View File

@@ -0,0 +1,95 @@
# Map Features Documentation Status
## Completion Summary
**Date**: 2026-02-13
**Task**: Create 9 comprehensive Map feature documentation files
**Status**: 4/9 COMPLETE (in progress)
## Completed Files (4053 lines)
1.**locations.md** (1154 lines) — Location management system
- Building + unit architecture
- NAR integration
- CSV import/export
- Geocoding integration
- Multi-provider support
2.**geocoding.md** (1029 lines) — Multi-provider geocoding service
- 6 provider fallback chain
- Confidence scoring
- Redis caching
- BullMQ bulk processing
- Provider health tracking
3.**cuts.md** (924 lines) — Geographic polygon overlays
- Polygon drawing workflow
- GeoJSON storage
- Point-in-polygon ray-casting
- Cut categories
- Completion tracking
4.**shifts.md** (946 lines) — Volunteer shift management
- Shift scheduling
- Capacity management
- Public signup
- TEMP user creation
- Email confirmations
## Remaining Files (5)
5. 🚧 **canvassing.md** — Canvassing session system
- Session lifecycle
- Visit recording
- Walking route algorithm
- GPS integration
- Volunteer + admin workflows
6. 🚧 **tracking.md** — GPS tracking system
- TrackingSession model
- TrackPoint recording
- Distance calculation
- Route visualization
- Live volunteer tracking
7. 🚧 **walk-sheets.md** — Printable walk sheets + QR codes
- MapSettings configuration
- QR code generation
- Walk sheet layout
- Cut export
- Browser print API
8. 🚧 **data-quality.md** — Geocoding quality dashboard
- Confidence metrics
- Provider success rate
- Ungeocoded locations
- Low-confidence alerts
- Duplicate detection
9. 🚧 **nar-import.md** — NAR 2025 electoral data import
- NAR format support
- Server-side streaming
- Address + Location join
- Lambert coordinate conversion
- Province code mapping
## Next Steps
Continue creating remaining 5 files following the established 12-section structure:
1. Overview
2. Architecture (Mermaid diagram)
3. Database Models
4. API Endpoints
5. Configuration
6. Admin Workflow
7. Public Workflow (if applicable)
8. Volunteer Workflow (if applicable)
9. Code Examples
10. Troubleshooting
11. Performance Considerations
12. Related Documentation
**Target**: 6,000-9,000 total lines across all 9 files (~670-1000 lines per file)
**Current**: 4,053 lines (4 files)
**Remaining**: ~2,950-4,950 lines (5 files)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,924 @@
# Geographic Polygon Overlays (Cuts)
## Overview
The cuts system provides polygon-based geographic organizing using customizable map overlays. Cuts enable campaigns to divide territories into canvassing zones, track completion progress, and assign volunteers to specific areas.
**Key Capabilities:**
- **Polygon Drawing**: Click-to-draw custom polygons on Leaflet maps
- **GeoJSON Storage**: Store complex polygons with coordinate precision
- **Spatial Queries**: Point-in-polygon filtering using ray-casting algorithm
- **Cut Categories**: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT classification
- **Visual Customization**: Configurable colors and opacity for map overlays
- **Bounds Calculation**: Auto-calculate bounding box from polygon coordinates
- **Completion Tracking**: Track canvassing progress by cut
- **Shift Assignment**: Link shifts to cuts for volunteer scheduling
- **Export Filtering**: Generate walk sheets for specific cuts
**Use Cases:**
- Electoral district mapping (wards, polling divisions)
- Canvassing zone organization
- Neighborhood targeting
- Volunteer territory assignment
- Walk sheet generation by area
- Progress tracking by geographic zone
- Multi-volunteer coordination
## Architecture
```mermaid
graph TD
A[Admin User] -->|Draws Polygon| B[CutDrawingMode]
B -->|Click Vertices| C[Leaflet Map]
C -->|Auto-Close Detection| D[GeoJSON Polygon]
D -->|POST /api/map/cuts| E[Cuts Service]
E -->|Calculate Bounds| F[Spatial Utils]
F -->|Save| G[(Cut Model)]
H[Public Map] -->|Load Cuts| I[GET /api/public/map/cuts]
I -->|Return GeoJSON| E
E -->|Query| G
I -->|Render| J[CutOverlays Component]
K[Canvass Session] -->|Start in Cut| L[Canvass Service]
L -->|Load Addresses| M[Locations Service]
M -->|Point-in-Polygon| F
F -->|Filter| N[(Location Model)]
O[Shift] -->|Assigned to Cut| G
G -->|1:N| O
P[Export Locations] -->|Filter by Cut| M
M -->|Query Polygon| F
style G fill:#e1f5ff
style N fill:#e1f5ff
style O fill:#e1f5ff
```
**Flow Description:**
1. **Admin draws cut** → Click vertices on map, auto-close detection, generate GeoJSON
2. **Save cut** → Calculate bounds from coordinates, store polygon in database
3. **Public map loads** → Query public cuts, render as colored overlays with opacity
4. **Canvass session starts** → Load addresses within cut polygon using ray-casting
5. **Shift assignment** → Link shift to cut for volunteer scheduling
6. **Export locations** → Filter by cut polygon to generate walk sheet
## Database Models
### Cut Model
See [Cut Model Documentation](../../database/models/map.md#cut-model) for full schema.
**Key Fields:**
- `name`: Cut display name (e.g., "Ward 5 - Downtown")
- `description`: Free-text notes about the cut
- `geojson`: Polygon coordinates in GeoJSON format (TEXT field)
- `bounds`: Auto-calculated bounding box `{minLat, maxLat, minLng, maxLng}` (JSON)
- `color`: Hex color for map overlay (default: `#3498db`)
- `opacity`: Opacity 0.0-1.0 for map rendering (default: 0.3)
- `category`: CUSTOM | WARD | NEIGHBORHOOD | DISTRICT
- `isPublic`: Show on public map
- `isOfficial`: Official electoral boundary (prevents accidental deletion)
- `showLocations`: Show location markers within cut on map
- `exportEnabled`: Allow walk sheet export for this cut
- `assignedTo`: Free-text assigned volunteer/team name
- `completionPercentage`: Auto-calculated canvassing progress (0-100)
**GeoJSON Format:**
```json
{
"type": "Polygon",
"coordinates": [
[
[-75.6972, 45.4215],
[-75.6980, 45.4220],
[-75.6960, 45.4230],
[-75.6950, 45.4225],
[-75.6972, 45.4215]
]
]
}
```
**Bounds Format:**
```json
{
"minLat": 45.4215,
"maxLat": 45.4230,
"minLng": -75.6980,
"maxLng": -75.6950
}
```
**Related Models:**
- [Shift](../../database/models/map.md#shift-model) — Volunteer shifts assigned to cut
- [CanvassSession](../../database/models/canvass.md#canvasssession-model) — Canvassing within cut
- [Location](../../database/models/map.md#location-model) — Filtered by cut polygon
## API Endpoints
See [Cuts Backend Module Documentation](../../backend/modules/map/cuts.md) for full API reference.
**Admin Endpoints:**
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/api/map/cuts` | MAP_ADMIN | List cuts with pagination, search, category filter |
| GET | `/api/map/cuts/stats` | MAP_ADMIN | Get cut statistics (total, by category) |
| GET | `/api/map/cuts/:id` | MAP_ADMIN | Get cut details |
| POST | `/api/map/cuts` | MAP_ADMIN | Create new cut with polygon |
| PATCH | `/api/map/cuts/:id` | MAP_ADMIN | Update cut |
| DELETE | `/api/map/cuts/:id` | MAP_ADMIN | Delete cut (blocked if `isOfficial=true`) |
| GET | `/api/map/cuts/:id/locations` | MAP_ADMIN | Get locations within cut polygon |
| GET | `/api/map/cuts/:id/progress` | MAP_ADMIN | Get canvassing progress for cut |
**Public Endpoints:**
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/api/public/map/cuts` | None | List public cuts (isPublic=true) |
| GET | `/api/public/map/cuts/:id` | None | Get public cut details |
## Configuration
### Environment Variables
No specific environment variables for cuts. Uses standard database and map settings.
### Cut Category Enum
```typescript
enum CutCategory {
CUSTOM // User-defined boundary
WARD // Municipal ward boundary
NEIGHBORHOOD // Neighborhood association boundary
DISTRICT // Electoral district boundary
}
```
### Default Values
| Field | Default | Description |
|-------|---------|-------------|
| `color` | `#3498db` | Blue color for overlay |
| `opacity` | `0.3` | 30% opacity (transparent) |
| `isPublic` | `false` | Hidden from public map |
| `isOfficial` | `false` | Can be deleted by admin |
| `showLocations` | `true` | Show location markers within cut |
| `exportEnabled` | `true` | Allow walk sheet export |
| `completionPercentage` | `0` | Auto-updated by canvass service |
## Admin Workflow
### Creating a Cut
**Step 1: Navigate to Cuts Page**
Navigate to **Map → Cuts** in the admin sidebar.
![CutsPage Screenshot Placeholder]
**Step 2: Open Drawing Tab**
Click **Drawing** tab to switch to map drawing mode.
**Step 3: Activate Drawing Mode**
Click **Draw Cut** button in the map controls. Map cursor changes to crosshair.
**Step 4: Click Vertices**
Click on the map to place polygon vertices:
- **First Click**: Start polygon
- **Additional Clicks**: Add vertices
- **Auto-Close**: When cursor near start point (within 10px), polygon auto-closes
**Step 5: Configure Cut**
Fill in the cut form (right sidebar):
- **Name**: "Ward 5 - Downtown"
- **Description**: "Central business district and residential blocks"
- **Category**: WARD
- **Color**: Choose from color picker (default: blue)
- **Opacity**: Slider 0-100 (default: 30%)
- **Is Public**: Toggle to show on public map
- **Is Official**: Toggle to prevent accidental deletion
**Step 6: Save Cut**
Click **Save Cut**. The system will:
1. Generate GeoJSON from vertices
2. Calculate bounding box
3. Save to database
4. Render polygon on map with configured color/opacity
### Editing a Cut
**Step 1: Select Cut**
On **Table** tab, click **Edit** button for a cut.
**Step 2: Update Fields**
Modify cut properties:
- **Name/Description**: Update text fields
- **Color/Opacity**: Adjust visual appearance
- **Category**: Change classification
- **Public/Official**: Toggle flags
**Step 3: Re-Draw Polygon (Optional)**
To change polygon shape:
1. Switch to **Drawing** tab
2. Click **Edit Cut** button
3. Delete old vertices (click vertices to remove)
4. Add new vertices
5. Auto-close polygon
**Step 4: Save Changes**
Click **Update** to save changes. Bounds are auto-recalculated if polygon changed.
### Viewing Locations in Cut
**Step 1: Select Cut**
Click cut row in table to select.
**Step 2: Click "View Locations"**
Click **View Locations** button.
**Step 3: View Filtered Table**
System displays locations within cut polygon:
- **Point-in-Polygon**: Uses ray-casting algorithm to filter
- **Count**: Number of locations within cut
- **Support Breakdown**: Count by support level
**Step 4: Export Locations**
Click **Export CSV** to download locations for walk sheet generation.
### Assigning Cut to Shift
**Step 1: Create/Edit Shift**
On **Map → Shifts** page, create or edit a shift.
**Step 2: Select Cut**
In shift form, choose cut from **Cut** dropdown.
**Step 3: Save Shift**
Shift is now linked to cut. Volunteers will see cut name on shift details.
### Tracking Cut Completion
**Step 1: View Cut Progress**
On CutsPage, click **Progress** button for a cut.
**Step 2: View Metrics**
System displays:
- **Completion Percentage**: Auto-calculated from canvass visits
- **Total Addresses**: Count of addresses within cut
- **Visited**: Count of addresses with CanvassVisit records
- **Outstanding**: Remaining addresses to visit
**Step 3: View Canvass Activity**
Table shows recent canvass visits within cut:
- **Volunteer Name**: Who visited
- **Visit Date**: When visited
- **Outcome**: Visit result (SPOKE_WITH, NOT_HOME, etc.)
- **Support Level**: Updated support level (if applicable)
## Public Workflow
**Public users can view cut overlays on the interactive map.**
**Step 1: Navigate to Public Map**
Visit `/map` (no authentication required).
**Step 2: Toggle Cut Overlays**
Click **Cuts** button in map controls to open overlay panel.
**Step 3: Select Cuts**
Check/uncheck cuts to show/hide on map:
- **Color Legend**: Shows cut name and color
- **Opacity**: Semi-transparent overlays don't obscure markers
- **Multiple Cuts**: Show multiple cuts simultaneously
**Step 4: View Cut Details**
Click on a cut polygon to view:
- **Cut Name**: Displayed in popup
- **Category**: Ward, Neighborhood, etc.
- **Assigned To**: Volunteer/team name (if configured)
## Volunteer Workflow
**Volunteers interact with cuts via shift assignments.**
**Step 1: View Assigned Shifts**
On **Volunteer → My Assignments** page, view shifts with cut assignments.
**Step 2: Start Canvass Session**
Click **Start Canvass** on a shift. Redirects to `/volunteer/canvass/:cutId`.
**Step 3: View Cut on Map**
Full-screen map shows:
- **Cut Polygon**: Highlighted boundary
- **Locations Within Cut**: Filtered to cut polygon only
- **Walking Route**: Optimal route through cut locations
See [Canvassing Documentation](./canvassing.md) for full volunteer workflow.
## Code Examples
### Cut Service Create (Backend)
```typescript
// api/src/modules/map/cuts/cuts.service.ts
import { parseGeoJsonPolygon, calculateBounds } from '../../../utils/spatial';
async create(data: CreateCutInput, userId: string) {
// Auto-calculate bounds from geojson if not provided
let boundsStr = data.bounds;
if (!boundsStr) {
try {
const rings = parseGeoJsonPolygon(data.geojson);
const allCoords = rings.flat();
const bounds = calculateBounds(allCoords);
boundsStr = JSON.stringify(bounds);
} catch {
// Bounds calculation optional
}
}
const cut = await prisma.cut.create({
data: {
name: data.name,
description: data.description,
color: data.color,
opacity: data.opacity,
category: data.category,
isPublic: data.isPublic,
isOfficial: data.isOfficial,
geojson: data.geojson,
bounds: boundsStr,
showLocations: data.showLocations,
exportEnabled: data.exportEnabled,
assignedTo: data.assignedTo,
createdByUserId: userId,
},
});
return cut;
}
```
### Bounds Calculation (Backend)
```typescript
// api/src/utils/spatial.ts
export function calculateBounds(coordinates: number[][]): {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
} {
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const coord of coordinates) {
const lng = coord[0]!;
const lat = coord[1]!;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
return { minLat, maxLat, minLng, maxLng };
}
```
### Point-in-Polygon Filter (Backend)
```typescript
// api/src/modules/map/cuts/cuts.service.ts
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
async getLocationsInCut(cutId: string) {
const cut = await prisma.cut.findUnique({
where: { id: cutId },
select: { geojson: true },
});
if (!cut?.geojson) {
throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
}
// Get all locations (or use bounds for optimization)
const locations = await prisma.location.findMany({
select: {
id: true,
latitude: true,
longitude: true,
address: true,
},
});
// Parse polygon coordinates
const polygons = parseGeoJsonPolygon(cut.geojson);
// Filter locations using ray-casting algorithm
const filtered = locations.filter((loc) => {
const lat = Number(loc.latitude);
const lng = Number(loc.longitude);
return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
});
return filtered;
}
```
### Ray-Casting Algorithm (Backend)
```typescript
// api/src/utils/spatial.ts
export function isPointInPolygon(
lat: number,
lng: number,
polygonCoords: number[][]
): boolean {
let inside = false;
for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {
const xi = polygonCoords[i]![1]!; // lat
const yi = polygonCoords[i]![0]!; // lng
const xj = polygonCoords[j]![1]!;
const yj = polygonCoords[j]![0]!;
const intersect = ((yi > lng) !== (yj > lng)) &&
(lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
```
### Cut Drawing Mode (Frontend)
```typescript
// admin/src/components/map/CutDrawingMode.tsx
import { useState, useEffect } from 'react';
import { useMapEvents } from 'react-leaflet';
import type { LatLng } from 'leaflet';
interface CutDrawingModeProps {
onPolygonComplete: (vertices: LatLng[]) => void;
}
export default function CutDrawingMode({ onPolygonComplete }: CutDrawingModeProps) {
const [vertices, setVertices] = useState<LatLng[]>([]);
const [isDrawing, setIsDrawing] = useState(true);
useMapEvents({
click(e) {
if (!isDrawing) return;
const newVertex = e.latlng;
// Auto-close detection: if click near first vertex (within 10px)
if (vertices.length >= 3) {
const firstVertex = vertices[0]!;
const map = e.target;
const firstPoint = map.latLngToContainerPoint(firstVertex);
const newPoint = map.latLngToContainerPoint(newVertex);
const distance = Math.sqrt(
Math.pow(firstPoint.x - newPoint.x, 2) +
Math.pow(firstPoint.y - newPoint.y, 2)
);
if (distance < 10) {
// Auto-close polygon
setIsDrawing(false);
onPolygonComplete(vertices);
return;
}
}
// Add vertex
setVertices([...vertices, newVertex]);
},
});
return (
<>
{/* Render temporary polygon while drawing */}
{vertices.length >= 2 && (
<Polygon positions={vertices} pathOptions={{ color: '#3498db', opacity: 0.5 }} />
)}
{/* Render vertex markers */}
{vertices.map((v, i) => (
<CircleMarker
key={i}
center={v}
radius={5}
pathOptions={{ color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1 }}
/>
))}
</>
);
}
```
### Cut Overlays Rendering (Frontend)
```typescript
// admin/src/components/map/CutOverlays.tsx
import { Polygon, Popup } from 'react-leaflet';
import type { Cut } from '@/types/api';
interface CutOverlaysProps {
cuts: Cut[];
visibleCutIds: string[];
}
export default function CutOverlays({ cuts, visibleCutIds }: CutOverlaysProps) {
return (
<>
{cuts
.filter((cut) => visibleCutIds.includes(cut.id))
.map((cut) => {
const geojson = JSON.parse(cut.geojson);
// GeoJSON uses [lng, lat], Leaflet uses [lat, lng]
const positions = geojson.coordinates[0].map(([lng, lat]: number[]) => [lat, lng]);
return (
<Polygon
key={cut.id}
positions={positions}
pathOptions={{
color: cut.color,
fillColor: cut.color,
fillOpacity: cut.opacity,
weight: 2,
}}
>
<Popup>
<div>
<strong>{cut.name}</strong>
<br />
{cut.category}
{cut.assignedTo && (
<>
<br />
Assigned to: {cut.assignedTo}
</>
)}
</div>
</Popup>
</Polygon>
);
})}
</>
);
}
```
### Convert Leaflet Polygon to GeoJSON (Frontend)
```typescript
// admin/src/pages/CutsPage.tsx
const handleSaveCut = async (vertices: LatLng[]) => {
// Convert Leaflet [lat, lng] to GeoJSON [lng, lat]
const coordinates = vertices.map((v) => [v.lng, v.lat]);
// Close polygon (first vertex === last vertex)
coordinates.push(coordinates[0]!);
const geojson = {
type: 'Polygon',
coordinates: [coordinates],
};
try {
const { data } = await api.post<Cut>('/map/cuts', {
name: cutName,
description: cutDescription,
geojson: JSON.stringify(geojson),
color: cutColor,
opacity: cutOpacity,
category: cutCategory,
isPublic: isPublic,
isOfficial: isOfficial,
});
message.success('Cut created');
fetchCuts();
} catch (error) {
message.error('Failed to create cut');
}
};
```
## Troubleshooting
### Issue: Polygon Not Closing
**Symptoms:**
- Clicking near start point doesn't auto-close polygon
- Polygon remains open after many vertices
- "Save Cut" button disabled
**Causes:**
- Auto-close distance threshold too small
- Mouse click precision issues on mobile
- Map zoom level affecting pixel distance calculation
**Solutions:**
1. **Increase auto-close threshold**:
```typescript
// admin/src/components/map/CutDrawingMode.tsx
const AUTO_CLOSE_DISTANCE_PX = 15; // Was 10, increase to 15
if (distance < AUTO_CLOSE_DISTANCE_PX) {
// Auto-close polygon
}
```
2. **Manual close button**:
Add explicit "Close Polygon" button for mobile users:
```typescript
<Button onClick={() => {
if (vertices.length >= 3) {
onPolygonComplete(vertices);
}
}}>
Close Polygon
</Button>
```
### Issue: Point-in-Polygon Returns Wrong Results
**Symptoms:**
- Locations outside cut polygon included in canvass session
- Locations inside cut polygon excluded
- Export CSV missing locations
**Causes:**
- Coordinate order mismatch (GeoJSON [lng, lat] vs Leaflet [lat, lng])
- Polygon not properly closed (first vertex !== last vertex)
- Ray-casting algorithm bug with edge cases
**Solutions:**
1. **Verify coordinate order**:
```typescript
// GeoJSON uses [lng, lat]
const geojson = {
type: 'Polygon',
coordinates: [
[
[-75.6972, 45.4215], // [lng, lat]
[-75.6980, 45.4220],
// ...
]
]
};
// Leaflet uses [lat, lng]
<Polygon positions={[[45.4215, -75.6972], [45.4220, -75.6980]]} />
```
2. **Verify polygon closure**:
```sql
-- Check if polygon is properly closed
SELECT id, name,
geojson::json->'coordinates'->0->0 as first_vertex,
geojson::json->'coordinates'->0->-1 as last_vertex
FROM "Cut"
WHERE id = 'YOUR_CUT_ID';
-- First and last should be identical
```
3. **Test with known points**:
```bash
# Test point-in-polygon directly
curl -X POST http://localhost:4000/api/map/cuts/YOUR_CUT_ID/test-point \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude":45.4220,"longitude":-75.6975}'
```
### Issue: Cut Rendering Performance Slow
**Symptoms:**
- Map lags when rendering multiple cuts
- Browser freezes with >10 cuts visible
- Polygon rendering takes >2 seconds
**Causes:**
- Too many polygon vertices (complex boundaries)
- Multiple cut overlays rendered simultaneously
- No polygon simplification
**Solutions:**
1. **Simplify complex polygons**:
Use Turf.js simplify algorithm to reduce vertices:
```typescript
import * as turf from '@turf/turf';
const simplified = turf.simplify(polygon, {
tolerance: 0.0001, // Adjust based on zoom level
highQuality: true
});
```
2. **Lazy render cuts**:
Only render cuts within current map bounds:
```typescript
const visibleCuts = cuts.filter((cut) => {
const bounds = JSON.parse(cut.bounds);
const mapBounds = map.getBounds();
return mapBounds.intersects([
[bounds.minLat, bounds.minLng],
[bounds.maxLat, bounds.maxLng]
]);
});
```
3. **Use Canvas renderer**:
For large polygons, use Leaflet Canvas renderer instead of SVG:
```typescript
<Polygon
positions={positions}
renderer={L.canvas()}
pathOptions={{ color: cut.color }}
/>
```
## Performance Considerations
### Spatial Query Optimization
**Bounds Pre-Filter:**
Always pre-filter by bounding box before point-in-polygon:
```typescript
async getLocationsInCut(cutId: string) {
const cut = await prisma.cut.findUnique({ where: { id: cutId } });
const bounds = JSON.parse(cut.bounds);
// Pre-filter by bounds (fast, uses index)
const candidates = await prisma.location.findMany({
where: {
latitude: {
gte: new Prisma.Decimal(bounds.minLat),
lte: new Prisma.Decimal(bounds.maxLat),
},
longitude: {
gte: new Prisma.Decimal(bounds.minLng),
lte: new Prisma.Decimal(bounds.maxLng),
},
},
});
// Then apply point-in-polygon (slower, but fewer candidates)
const polygons = parseGeoJsonPolygon(cut.geojson);
return candidates.filter((loc) => {
const lat = Number(loc.latitude);
const lng = Number(loc.longitude);
return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
});
}
```
**Performance Impact:**
- **Without bounds pre-filter**: 10,000 locations → 10,000 point-in-polygon checks
- **With bounds pre-filter**: 10,000 locations → 500 candidates → 500 point-in-polygon checks (20x faster)
### Polygon Simplification
**Reduce Vertices for Large Cuts:**
Use Douglas-Peucker algorithm to simplify polygons while preserving shape:
```typescript
import * as turf from '@turf/turf';
function simplifyPolygon(geojson: string, tolerance: number = 0.0001): string {
const polygon = JSON.parse(geojson);
const simplified = turf.simplify(polygon, { tolerance, highQuality: true });
return JSON.stringify(simplified);
}
// Usage: simplify when importing official boundaries (e.g., electoral districts)
const simplifiedGeojson = simplifyPolygon(officialBoundary, 0.0005);
```
**Tolerance Guidelines:**
- **0.00001**: High precision (±1m), use for small neighborhoods
- **0.0001**: Medium precision (±10m), use for wards
- **0.001**: Low precision (±100m), use for large districts
### Caching Cut Queries
**Cache Frequently Used Cuts:**
```typescript
// Cache cut polygons in Redis for fast repeated queries
const CACHE_KEY = `CUT_POLYGON:${cutId}`;
const cached = await redis.get(CACHE_KEY);
if (cached) {
return JSON.parse(cached);
}
const cut = await prisma.cut.findUnique({ where: { id: cutId } });
await redis.setex(CACHE_KEY, 3600, JSON.stringify(cut)); // 1 hour TTL
return cut;
```
## Related Documentation
**Backend Modules:**
- [Cuts Backend Module](../../backend/modules/map/cuts.md) — API implementation
- [Spatial Utils](../../backend/modules/utils/spatial.md) — Point-in-polygon algorithms
- [Locations Service](../../backend/modules/map/locations.md) — Spatial filtering
**Frontend Pages:**
- [CutsPage](../../frontend/pages/admin/cuts-page.md) — Admin CRUD interface
- [CutDrawingMode](../../frontend/components/cut-drawing-mode.md) — Polygon drawing
- [CutOverlays](../../frontend/components/cut-overlays.md) — Map rendering
**Database:**
- [Cut Model](../../database/models/map.md#cut-model) — Cut schema
- [Spatial Queries](../../database/queries.md#spatial-queries) — Optimization tips
**Features:**
- [Locations](./locations.md) — Location filtering by cut
- [Shifts](./shifts.md) — Shift assignment to cuts
- [Canvassing](./canvassing.md) — Canvassing within cut boundaries
- [Walk Sheets](./walk-sheets.md) — Export locations by cut

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,353 @@
# Map Module
The Map module provides comprehensive location management, geographic organization, volunteer coordination, and door-to-door canvassing capabilities. It combines GIS features with volunteer management for effective ground campaigns.
## Overview
The Map module consists of ten integrated components:
1. **[Locations](locations.md)** - Location database with geocoding
2. **[Geocoding](geocoding.md)** - Multi-provider address → coordinate conversion
3. **[NAR Import](nar-import.md)** - Canadian electoral data import
4. **[Cuts](cuts.md)** - Geographic polygon organization
5. **[Shifts](shifts.md)** - Volunteer shift scheduling
6. **[Canvassing](canvassing.md)** - Door-to-door canvassing system
7. **[Tracking](tracking.md)** - GPS tracking sessions
8. **[Walk Sheets](walk-sheets.md)** - Printable canvass materials
9. **[Data Quality](data-quality.md)** - Geocoding quality monitoring
10. **Map Features Status** - Feature completion tracking
## Features
### Location Management
- Location CRUD with address, coordinates, metadata
- CSV import/export (100,000+ records supported)
- Multi-provider geocoding (6 providers)
- Bulk geocoding with queue
- NAR 2025 server-side import (Canadian electoral data)
- Address standardization
- Visit tracking integration
### Geographic Organization
- Polygon-based geographic cuts
- GeoJSON import/export
- Point-in-polygon queries
- Cut-based location assignment
- Spatial bounds calculation
- Map visualization
### Volunteer Coordination
- Shift scheduling with cut assignment
- Volunteer signup (authenticated + anonymous)
- Email confirmations
- Temp user creation for walk-ins
- Shift capacity tracking
### Canvassing System
- GPS-enabled mobile interface
- Walking route algorithm (nearest-neighbor)
- Visit outcome recording (7 outcomes)
- Session management (start/end/abandon)
- Real-time progress tracking
- Admin monitoring dashboard
- Printable walk sheets with QR codes
### Map Display
- Public interactive Leaflet map
- Color-coded markers by visit status
- Polygon overlays for cuts
- Geolocate button
- Fullscreen mode
- Legend and controls
## User Flow
### Admin Experience
1. **Import Locations** (`/app/map/locations`)
- Upload CSV or NAR data
- Geocode addresses
- Review quality metrics
- Bulk operations
2. **Create Cuts** (`/app/map/cuts`)
- Draw polygons on map
- Name and describe cut
- Assign locations (automatic)
- Export for printing
3. **Schedule Shifts** (`/app/map/shifts`)
- Create shift with cut assignment
- Set date/time/capacity
- Email all volunteers
- Monitor signups
4. **Monitor Canvassing** (`/app/canvass/dashboard`)
- View active sessions
- Track visit progress
- Check leaderboard
- Review activity feed
5. **Print Materials** (`/app/canvass/walk-sheet`)
- Select cut
- Generate walk sheet PDF
- QR codes for quick access
- Browser print
### Volunteer Experience
1. **View Assignments** (`/volunteer/assignments`)
- See upcoming shifts
- Cut information
- Start canvass button
2. **Canvass** (`/volunteer/canvass/:cutId`)
- Full-screen map with GPS
- Follow walking route
- Click markers to record visits
- Select outcomes + notes
- Track progress
3. **Review Activity** (`/volunteer/activity`)
- Visit history
- Outcome breakdown
- Session statistics
### Public Experience
1. **View Map** (`/map`)
- Browse locations
- View cuts
- See visit status (color-coded)
- Geolocate self
2. **Sign Up for Shifts** (`/shifts`)
- Browse available shifts
- Signup with email
- Receive confirmation
## Architecture
### Backend Components
**Modules:**
- `api/src/modules/map/locations/` - Location CRUD + geocoding + NAR import
- `api/src/modules/map/geocoding/` - Multi-provider geocoding service
- `api/src/modules/map/cuts/` - Polygon CRUD + spatial queries
- `api/src/modules/map/shifts/` - Shift CRUD + signups
- `api/src/modules/map/canvass/` - Session + visit tracking
- `api/src/modules/map/tracking/` - GPS tracking (future)
- `api/src/modules/map/settings/` - Map settings singleton
**Services:**
- `api/src/services/geocoding.service.ts` - Geocoding abstraction
- `api/src/services/geocode-queue.service.ts` - Async geocoding
**Utilities:**
- `api/src/utils/spatial.ts` - Point-in-polygon, haversine, bounds, centroid
**Database Models:**
- `Location` - Address, coordinates, metadata, visit tracking
- `Cut` - Name, GeoJSON polygon
- `Shift` - Date/time, cut, capacity, signups
- `CanvassSession` - Session tracking, start/end times
- `CanvassVisit` - Visit outcomes, notes, GPS
- `MapSettings` - Map center/zoom, walk sheet config
### Frontend Components
**Admin Pages:**
- `admin/src/pages/LocationsPage.tsx` - Location management
- `admin/src/pages/CutsPage.tsx` - Cut management
- `admin/src/pages/ShiftsPage.tsx` - Shift management
- `admin/src/pages/CanvassDashboardPage.tsx` - Canvass monitoring
- `admin/src/pages/WalkSheetPage.tsx` - Printable materials
- `admin/src/pages/DataQualityDashboardPage.tsx` - Quality metrics
**Public Pages:**
- `admin/src/pages/public/MapPage.tsx` - Public map
- `admin/src/pages/public/ShiftsPage.tsx` - Shift signup
**Volunteer Pages:**
- `admin/src/pages/volunteer/VolunteerMapPage.tsx` - GPS canvass map
- `admin/src/pages/volunteer/VolunteerShiftsPage.tsx` - Assignments
- `admin/src/pages/volunteer/MyActivityPage.tsx` - Activity history
**Map Components:**
- `admin/src/components/map/MapControls.tsx` - Control buttons
- `admin/src/components/map/AddLocationMode.tsx` - Click-to-add
- `admin/src/components/map/CutDrawingMode.tsx` - Polygon drawing
- `admin/src/components/map/CutOverlays.tsx` - GeoJSON rendering
**Canvass Components:**
- `admin/src/components/canvass/GPSTracker.tsx` - GPS tracking
- `admin/src/components/canvass/WalkingRouteLine.tsx` - Route display
- `admin/src/components/canvass/VisitRecordingForm.tsx` - Outcome form
## Configuration
### Environment Variables
```bash
# Geocoding Providers
MAPBOX_ACCESS_TOKEN=pk_...
GOOGLE_GEOCODE_API_KEY=...
PELIAS_API_URL=http://pelias:4000
# NAR Import
NAR_DATA_DIR=/data # NAR file directory (Docker volume)
# Map Settings
MAP_DEFAULT_LAT=43.65 # Default map center
MAP_DEFAULT_LNG=-79.38
MAP_DEFAULT_ZOOM=12
```
### Map Settings
Configurable via admin UI (`/app/map/settings`):
- Default map center (lat/lng)
- Default zoom level
- Walk sheet header/footer
- Display preferences
## Geocoding
### Supported Providers
1. **Nominatim** (OpenStreetMap) - Free, rate limited
2. **ArcGIS** - Free tier available
3. **Photon** - Free, self-hosted option
4. **Mapbox** - API key required
5. **Google Geocoding** - API key required
6. **Pelias** - Self-hosted option
### Geocoding Strategy
1. Try provider 1 (Nominatim)
2. If fails, try provider 2 (ArcGIS)
3. Continue through providers
4. Cache successful results
5. Track quality metrics
### Bulk Geocoding
- BullMQ queue for async processing
- Batch processing (100 locations/batch)
- Provider rotation to avoid rate limits
- Progress tracking
- Error handling and retry
## NAR Import
Canadian electoral data (NAR 2025 format):
- **Address files** - Civic addresses with coordinates (EPSG:3347)
- **Location files** - Building locations with lat/lng
- **Join on LOC_GUID** - Combine address + coordinates
- **Server-side streaming** - Memory-efficient for large files
- **Filters** - Province, city, postal code, cut, residential-only
**Import Flow:**
1. Scan NAR data directory
2. List available provinces
3. Stream Address + Location files
4. Join on LOC_GUID
5. Transform coordinates (proj4)
6. Filter and insert locations
## Spatial Algorithms
### Point-in-Polygon
Ray-casting algorithm:
- Count ray intersections with polygon edges
- Odd count = inside, even count = outside
- Supports holes in polygons
- Used for cut assignment
### Walking Route
Nearest-neighbor algorithm:
1. Start at closest location to shift start point
2. For each location:
- Find nearest unvisited location
- Add to route
- Mark as visited
3. Return ordered list
### Haversine Distance
Great-circle distance between coordinates:
- Returns distance in kilometers
- Used for proximity sorting
- Route optimization
## API Endpoints
### Locations
```
GET /api/locations # List locations
POST /api/locations # Create location
GET /api/locations/:id # Get location
PATCH /api/locations/:id # Update location
DELETE /api/locations/:id # Delete location
POST /api/locations/import # CSV import
GET /api/locations/export # CSV export
POST /api/locations/geocode # Bulk geocode
```
### Cuts
```
GET /api/cuts # List cuts
POST /api/cuts # Create cut
GET /api/cuts/:id # Get cut
PATCH /api/cuts/:id # Update cut
DELETE /api/cuts/:id # Delete cut
POST /api/cuts/:id/assign-locations # Assign locations
```
### Shifts
```
GET /api/shifts # List shifts
POST /api/shifts # Create shift
GET /api/shifts/:id # Get shift
PATCH /api/shifts/:id # Update shift
DELETE /api/shifts/:id # Delete shift
POST /api/shifts/:id/signup # Signup for shift
```
### Canvassing
```
POST /api/canvass/session/start # Start session
POST /api/canvass/session/end # End session
GET /api/canvass/session # Get active session
POST /api/canvass/visit # Record visit
GET /api/canvass/route/:cutId # Get walking route
GET /api/canvass/dashboard # Dashboard stats
```
## Related Documentation
- [Locations](locations.md)
- [Geocoding](geocoding.md)
- [NAR Import](nar-import.md)
- [Cuts](cuts.md)
- [Shifts](shifts.md)
- [Canvassing](canvassing.md)
- [Walk Sheets](walk-sheets.md)
- [Data Quality](data-quality.md)
- [Backend Locations Module](../../backend/modules/locations.md)
- [Backend Canvass Module](../../backend/modules/canvass.md)
- [Spatial Utilities](../../backend/utilities/index.md)
- [Map Organizer Guide](../../user-guides/map-organizer-guide.md)
- [Volunteer Guide](../../user-guides/volunteer-guide.md)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,946 @@
# Volunteer Shift Management
## Overview
The shifts system enables campaigns to organize volunteer activities with time-based scheduling, capacity management, and cut assignment. It supports public shift signup with automatic TEMP user creation for unauthenticated volunteers.
**Key Capabilities:**
- **Shift Scheduling**: Date, start/end times (HH:MM format), location
- **Capacity Management**: Max volunteers, auto-status updates (OPEN → FULL)
- **Cut Assignment**: Link shifts to geographic cuts for territory-based organizing
- **Public Signup**: Unauthenticated users can signup (creates TEMP user)
- **Email Confirmations**: Auto-send confirmation emails on signup
- **Signup Tracking**: Source tracking (AUTHENTICATED, PUBLIC, ADMIN)
- **Status Lifecycle**: OPEN, FULL, CANCELLED workflow
- **Bulk Operations**: Email all volunteers, export signups CSV
**Use Cases:**
- Canvassing shift scheduling
- Phone bank volunteer coordination
- Event volunteer management
- Door-knocking territory assignment
- Get-out-the-vote (GOTV) shifts
- Public volunteer recruitment
- Volunteer confirmation emails
## Architecture
```mermaid
graph TD
A[Admin] -->|Creates Shift| B[ShiftsPage]
B -->|POST /api/map/shifts| C[Shifts Service]
C -->|Validate| D[Shift Model]
D -->|Linked To| E[Cut Model]
F[Public User] -->|Browse Shifts| G[Public ShiftsPage]
G -->|GET /api/public/map/shifts| C
C -->|Filter upcoming=true| D
F -->|Signup| H[Signup Modal]
H -->|POST /api/public/map/shifts/:id/signup| C
C -->|Check Capacity| D
C -->|Create TEMP User| I[User Service]
C -->|Create Signup| J[ShiftSignup Model]
C -->|Send Email| K[Email Service]
L[Volunteer] -->|View Assignments| M[VolunteerShiftsPage]
M -->|GET /api/map/canvass/volunteer/assignments| N[Canvass Service]
N -->|Filter by userId| J
N -->|Include Cut| E
D -->|1:N| J
D -->|N:1| E
style D fill:#e1f5ff
style J fill:#e1f5ff
style E fill:#e1f5ff
style I fill:#e8f5e9
```
**Flow Description:**
1. **Admin creates shift** → Validates date/time, assigns cut (optional), saves to database
2. **Public user browses** → Query upcoming shifts (isPublic=true, date >=today), display cards
3. **Public signup** → Check capacity, create TEMP user if unauthenticated, create signup record, send confirmation email
4. **Volunteer views assignments** → Query signups for current user, include shift + cut details
5. **Shift capacity check** → Auto-update status to FULL when currentVolunteers >= maxVolunteers
## Database Models
### Shift Model
See [Shift Model Documentation](../../database/models/map.md#shift-model) for full schema.
**Key Fields:**
- `title`: Shift name (e.g., "Saturday Canvassing - Downtown")
- `description`: Free-text shift details
- `date`: Shift date (Date type, not DateTime)
- `startTime`: Start time in HH:MM format (24-hour)
- `endTime`: End time in HH:MM format (24-hour)
- `location`: Meeting point address/description
- `maxVolunteers`: Maximum volunteer capacity
- `currentVolunteers`: Current signup count (auto-updated)
- `status`: OPEN | FULL | CANCELLED
- `isPublic`: Show on public shifts page
- `cutId`: Optional foreign key to Cut (territory assignment)
- `createdBy`: User ID who created shift
**Status Enum:**
```typescript
enum ShiftStatus {
OPEN // Accepting signups
FULL // At capacity
CANCELLED // Cancelled by admin
}
```
### ShiftSignup Model
See [ShiftSignup Model Documentation](../../database/models/map.md#shiftsignup-model) for full schema.
**Key Fields:**
- `shiftId`: Foreign key to Shift
- `userId`: Foreign key to User (optional for TEMP users)
- `userEmail`: Email address (required, used for confirmations)
- `userName`: Display name
- `userPhone`: Phone number (optional)
- `status`: CONFIRMED | CANCELLED | NO_SHOW
- `signupDate`: When signup occurred
- `signupSource`: AUTHENTICATED | PUBLIC | ADMIN
- `notes`: Admin notes about signup
**Signup Source Enum:**
```typescript
enum SignupSource {
AUTHENTICATED // Logged-in user signup
PUBLIC // Public signup (creates TEMP user)
ADMIN // Admin created signup
}
```
**Signup Status Enum:**
```typescript
enum SignupStatus {
CONFIRMED // Signup active
CANCELLED // Volunteer cancelled
NO_SHOW // Marked as no-show by admin
}
```
**Related Models:**
- [Shift](../../database/models/map.md#shift-model) — Parent shift
- [User](../../database/models/user.md) — Volunteer account (TEMP role for public signups)
- [Cut](../../database/models/map.md#cut-model) — Geographic territory assignment
- [CanvassSession](../../database/models/canvass.md#canvasssession-model) — Linked to shift for canvassing
## API Endpoints
See [Shifts Backend Module Documentation](../../backend/modules/map/shifts.md) for full API reference.
**Admin Endpoints:**
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/api/map/shifts` | MAP_ADMIN | List shifts with pagination, search, filters |
| GET | `/api/map/shifts/stats` | MAP_ADMIN | Get shift statistics (total, upcoming, by status) |
| GET | `/api/map/shifts/:id` | MAP_ADMIN | Get shift details with signups |
| POST | `/api/map/shifts` | MAP_ADMIN | Create new shift |
| PATCH | `/api/map/shifts/:id` | MAP_ADMIN | Update shift |
| DELETE | `/api/map/shifts/:id` | MAP_ADMIN | Delete shift (cascade signups) |
| POST | `/api/map/shifts/:id/signups` | MAP_ADMIN | Manually add signup |
| PATCH | `/api/map/shifts/:id/signups/:signupId` | MAP_ADMIN | Update signup (change status, notes) |
| DELETE | `/api/map/shifts/:id/signups/:signupId` | MAP_ADMIN | Delete signup |
| POST | `/api/map/shifts/:id/email-volunteers` | MAP_ADMIN | Send email to all shift volunteers |
**Public Endpoints:**
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/api/public/map/shifts` | None | List upcoming public shifts (isPublic=true, date >=today) |
| GET | `/api/public/map/shifts/:id` | None | Get public shift details |
| POST | `/api/public/map/shifts/:id/signup` | None | Public signup (creates TEMP user if unauthenticated) |
**Volunteer Endpoints:**
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/api/map/canvass/volunteer/assignments` | Any logged-in user | Get shifts user signed up for |
| DELETE | `/api/map/shifts/:id/signups/cancel` | Any logged-in user | Cancel own signup |
## Configuration
### Environment Variables
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `EMAIL_TEST_MODE` | boolean | `false` | Send confirmation emails to MailHog (dev) |
| `SMTP_HOST` | string | - | SMTP server for confirmation emails |
| `SMTP_PORT` | number | `587` | SMTP port |
| `SMTP_USER` | string | - | SMTP username |
| `SMTP_PASSWORD` | string | - | SMTP password |
### Email Templates
**Shift Confirmation Email:**
Subject: `Shift Confirmation - {{shift.title}}`
Body:
```
Hi {{userName}},
You're confirmed for:
{{shift.title}}
Date: {{shift.date}}
Time: {{shift.startTime}} - {{shift.endTime}}
Location: {{shift.location}}
{{#if shift.cut}}
Territory: {{shift.cut.name}}
{{/if}}
{{#if shift.description}}
Details:
{{shift.description}}
{{/if}}
To cancel your signup, reply to this email.
Thank you!
```
**Admin Email All Volunteers:**
Subject: Configurable by admin
Body: Configurable by admin (supports {{name}}, {{email}}, {{phone}} placeholders)
## Admin Workflow
### Creating a Shift
**Step 1: Navigate to Shifts Page**
Navigate to **Map → Shifts** in the admin sidebar.
![ShiftsPage Screenshot Placeholder]
**Step 2: Click "Add Shift"**
Click **+ Add Shift** button in the top-right corner.
**Step 3: Fill Shift Form**
Complete shift details:
- **Title**: "Saturday Canvassing - Ward 5"
- **Description**: "Door-knocking downtown, meet at campaign office"
- **Date**: Select date from calendar
- **Start Time**: "09:00" (24-hour format)
- **End Time**: "12:00"
- **Location**: "123 Campaign Office, Main St"
- **Max Volunteers**: 20
- **Cut**: Select from dropdown (optional)
- **Is Public**: Toggle to show on public shifts page
**Step 4: Save Shift**
Click **Create** to save shift. Status is automatically set to OPEN.
### Managing Signups
**Step 1: View Shift**
Click **Signups** button on a shift row to open signups drawer.
**Step 2: View Signup List**
Drawer displays:
- **Volunteer Name**: From signup or user account
- **Email**: Contact email
- **Phone**: Contact phone (if provided)
- **Signup Date**: When volunteer signed up
- **Source**: AUTHENTICATED | PUBLIC | ADMIN
- **Status**: CONFIRMED | CANCELLED | NO_SHOW
**Step 3: Manually Add Signup (Admin)**
Click **Add Signup** button in drawer:
- **Email**: Required (validates format)
- **Name**: Required
- **Phone**: Optional
- **Notes**: Admin notes
System will:
1. Check capacity (reject if FULL)
2. Create TEMP user if email not in database
3. Create signup with source=ADMIN
4. Send confirmation email
5. Update shift.currentVolunteers count
**Step 4: Mark No-Show**
Click **Mark No-Show** on signup row to update status. Useful for tracking volunteer reliability.
**Step 5: Delete Signup**
Click **Delete** to remove signup. Decrements shift.currentVolunteers count.
### Emailing All Volunteers
**Step 1: Click "Email All"**
On shift row, click **Email All** button.
**Step 2: Compose Email**
Modal opens with:
- **Subject**: Pre-filled with shift title
- **Message**: Rich text editor with placeholders
- **Placeholders**: {{name}}, {{email}}, {{phone}}, {{shift.title}}, {{shift.date}}, {{shift.startTime}}, {{shift.endTime}}
**Step 3: Preview**
Click **Preview** to see sample email with placeholders replaced.
**Step 4: Send**
Click **Send Email** to queue emails to all CONFIRMED volunteers. Uses BullMQ email queue for async processing.
### Updating Shift Status
**Step 1: Edit Shift**
Click **Edit** on shift row.
**Step 2: Change Status**
Update status dropdown:
- **OPEN**: Accepting signups
- **FULL**: At capacity (auto-set when currentVolunteers >= maxVolunteers)
- **CANCELLED**: Cancelled by admin
**Step 3: Save**
Click **Update**. If status changed to CANCELLED, optionally send cancellation email to all volunteers.
## Public Workflow
**Public users can browse and signup for shifts without authentication.**
**Step 1: Navigate to Public Shifts Page**
Visit `/shifts` (public route, no auth required).
**Step 2: Browse Shifts**
View upcoming shifts as cards:
- **Shift Title**: Large heading
- **Date/Time**: Formatted date + time range
- **Location**: Meeting point
- **Volunteers**: "5 / 20 spots filled" progress bar
- **Cut**: Territory name (if assigned)
- **Status Badge**: OPEN (green), FULL (red), CANCELLED (gray)
**Step 3: Filter Shifts**
Use filters:
- **Date**: Show only shifts on specific date
- **Status**: OPEN only (hide FULL/CANCELLED)
**Step 4: Click Signup**
Click **Signup** button on shift card. Modal opens.
**Step 5: Fill Signup Form**
Complete form:
- **Name**: Required
- **Email**: Required (validates format)
- **Phone**: Optional
**Step 6: Submit**
Click **Sign Up**. System will:
1. Check capacity (reject if FULL)
2. Create TEMP user with email (if not exists)
3. Create shift signup with source=PUBLIC
4. Send confirmation email
5. Update shift.currentVolunteers count
6. Auto-update status to FULL if at capacity
**Step 7: Receive Confirmation**
Check email for confirmation with shift details.
## Volunteer Workflow
**Authenticated volunteers can view assigned shifts and cancel signups.**
**Step 1: Login**
Login at `/login` with volunteer account.
**Step 2: Navigate to Assignments**
Navigate to **Volunteer → My Assignments**.
**Step 3: View Assigned Shifts**
Table displays:
- **Shift Title**: Linked to shift details
- **Date/Time**: Formatted
- **Location**: Meeting point
- **Cut**: Territory name (if assigned)
- **Status**: Signup status
**Step 4: View Shift Details**
Click shift title to view:
- **Description**: Full shift details
- **Volunteers**: List of other volunteers (names only, privacy protected)
- **Map**: If cut assigned, show cut polygon on map
**Step 5: Cancel Signup**
Click **Cancel Signup** button. Confirmation modal appears.
**Step 6: Confirm Cancellation**
Click **Confirm**. System will:
1. Update signup status to CANCELLED
2. Decrement shift.currentVolunteers count
3. Update shift status to OPEN if was FULL
4. Send cancellation confirmation email
## Code Examples
### Shift Service Create (Backend)
```typescript
// api/src/modules/map/shifts/shifts.service.ts
async create(data: CreateShiftInput, userId: string) {
const shift = await prisma.shift.create({
data: {
title: data.title,
description: data.description,
date: new Date(data.date),
startTime: data.startTime,
endTime: data.endTime,
location: data.location,
maxVolunteers: data.maxVolunteers,
isPublic: data.isPublic,
cutId: data.cutId,
createdBy: userId,
},
});
return shift;
}
```
### Public Signup (Backend)
```typescript
// api/src/modules/map/shifts/shifts.service.ts
import bcrypt from 'bcryptjs';
async publicSignup(shiftId: string, data: PublicSignupInput) {
const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift) {
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
// Check capacity
if (shift.currentVolunteers >= shift.maxVolunteers) {
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
}
// Find or create TEMP user
let user = await prisma.user.findUnique({ where: { email: data.email } });
if (!user) {
const password = generateReadablePassword(); // e.g., "BlueEagle42"
const hashedPassword = await bcrypt.hash(password, 10);
user = await prisma.user.create({
data: {
email: data.email,
name: data.name,
phone: data.phone,
password: hashedPassword,
role: 'TEMP',
},
});
logger.info('Created TEMP user for shift signup', {
email: data.email,
shiftId,
});
}
// Create signup
const signup = await prisma.shiftSignup.create({
data: {
shiftId,
userId: user.id,
userEmail: user.email,
userName: user.name ?? data.name,
userPhone: user.phone ?? data.phone,
signupSource: SignupSource.PUBLIC,
status: SignupStatus.CONFIRMED,
},
});
// Increment volunteer count
await prisma.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { increment: 1 },
status: shift.currentVolunteers + 1 >= shift.maxVolunteers
? ShiftStatus.FULL
: shift.status,
},
});
// Send confirmation email
await emailService.sendShiftConfirmation(user.email, shift, user.name ?? data.name);
recordShiftSignup('public');
return signup;
}
```
### Generate Readable Password (Backend)
```typescript
// api/src/modules/map/shifts/shifts.service.ts
const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
function generateReadablePassword(): string {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const num = Math.floor(Math.random() * 90) + 10;
return `${adj}${noun}${num}`;
}
// Example output: "BoldWolf72", "SwiftStar45"
```
### Shift Confirmation Email (Backend)
```typescript
// api/src/services/email.service.ts
async sendShiftConfirmation(
to: string,
shift: Shift,
userName: string
): Promise<void> {
const subject = `Shift Confirmation - ${shift.title}`;
const body = `
Hi ${userName},
You're confirmed for:
${shift.title}
Date: ${dayjs(shift.date).format('MMMM D, YYYY')}
Time: ${shift.startTime} - ${shift.endTime}
Location: ${shift.location}
${shift.description ? `\nDetails:\n${shift.description}\n` : ''}
To cancel your signup, reply to this email.
Thank you!
`;
await this.sendEmail({ to, subject, text: body });
}
```
### Public Shifts List (Frontend)
```typescript
// admin/src/pages/public/ShiftsPage.tsx
const fetchShifts = async () => {
try {
const { data } = await axios.get('/api/public/map/shifts', {
params: {
upcoming: true, // Only show future shifts
},
});
setShifts(data.shifts);
} catch (error) {
message.error('Failed to load shifts');
}
};
useEffect(() => {
fetchShifts();
}, []);
```
### Signup Modal (Frontend)
```typescript
// admin/src/pages/public/ShiftsPage.tsx
const handleSignup = async (values: any) => {
try {
await axios.post(`/api/public/map/shifts/${selectedShift.id}/signup`, {
name: values.name,
email: values.email,
phone: values.phone,
});
message.success('Signup successful! Check your email for confirmation.');
setSignupModalOpen(false);
signupForm.resetFields();
fetchShifts(); // Refresh to update volunteer count
} catch (error: any) {
if (error.response?.data?.code === 'SHIFT_FULL') {
message.error('This shift is now full. Please choose another shift.');
} else {
message.error('Signup failed. Please try again.');
}
}
};
```
### Volunteer Assignments (Frontend)
```typescript
// admin/src/pages/volunteer/VolunteerShiftsPage.tsx
const fetchAssignments = async () => {
try {
const { data } = await api.get('/map/canvass/volunteer/assignments');
setAssignments(data);
} catch (error) {
message.error('Failed to load assignments');
}
};
```
## Troubleshooting
### Issue: Shift Status Not Auto-Updating to FULL
**Symptoms:**
- Shift accepts signups beyond maxVolunteers
- Status remains OPEN even when at capacity
- currentVolunteers count incorrect
**Causes:**
- currentVolunteers not incremented on signup
- Signup deletion not decrementing count
- Race condition on concurrent signups
**Solutions:**
1. **Use database transaction** for capacity check + signup creation:
```typescript
await prisma.$transaction(async (tx) => {
const shift = await tx.shift.findUnique({
where: { id: shiftId },
select: { currentVolunteers: true, maxVolunteers: true },
});
if (shift.currentVolunteers >= shift.maxVolunteers) {
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
}
await tx.shiftSignup.create({ data: signupData });
await tx.shift.update({
where: { id: shiftId },
data: {
currentVolunteers: { increment: 1 },
status: shift.currentVolunteers + 1 >= shift.maxVolunteers
? ShiftStatus.FULL
: shift.status,
},
});
});
```
2. **Verify count matches reality**:
```sql
-- Check if currentVolunteers matches actual signup count
SELECT s.id, s.title, s.currentVolunteers,
COUNT(ss.id) as actual_signups
FROM "Shift" s
LEFT JOIN "ShiftSignup" ss ON s.id = ss."shiftId"
AND ss.status = 'CONFIRMED'
GROUP BY s.id
HAVING s."currentVolunteers" != COUNT(ss.id);
```
3. **Recalculate counts**:
```typescript
// Admin utility to fix counts
async function recalculateShiftCounts() {
const shifts = await prisma.shift.findMany();
for (const shift of shifts) {
const count = await prisma.shiftSignup.count({
where: {
shiftId: shift.id,
status: SignupStatus.CONFIRMED,
},
});
await prisma.shift.update({
where: { id: shift.id },
data: {
currentVolunteers: count,
status: count >= shift.maxVolunteers ? ShiftStatus.FULL : ShiftStatus.OPEN,
},
});
}
}
```
### Issue: Confirmation Emails Not Sending
**Symptoms:**
- Users signup successfully but no email received
- MailHog shows no emails in dev
- SMTP errors in API logs
**Causes:**
- EMAIL_TEST_MODE not set in dev
- SMTP credentials invalid
- Email service not configured
- Email in spam folder
**Solutions:**
1. **Check email service config**:
```bash
# Verify SMTP settings in .env
grep "SMTP_\|EMAIL_TEST_MODE" .env
# In development, use MailHog
EMAIL_TEST_MODE=true
# In production, configure SMTP
EMAIL_TEST_MODE=false
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
```
2. **Test email service**:
```bash
# Send test email via API
curl -X POST http://localhost:4000/api/test-email \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"to":"test@example.com","subject":"Test","text":"Test email"}'
```
3. **Check MailHog in dev**:
```bash
# Access MailHog UI
open http://localhost:8025
# View email queue in BullMQ
docker compose exec redis redis-cli KEYS "bull:email-queue:*"
```
4. **Check spam folder** (production):
Add SPF/DKIM/DMARC records to domain to improve deliverability.
### Issue: TEMP User Password Security
**Symptoms:**
- TEMP users can't login with generated password
- Password doesn't meet complexity requirements
- Account locked after signup
**Causes:**
- Generated password doesn't meet 12-char minimum
- Password missing uppercase/lowercase/digit
- Password not sent to user (they can't login)
**Solutions:**
1. **Ensure generated password meets policy**:
```typescript
function generateReadablePassword(): string {
// Must meet: 12+ chars, uppercase, lowercase, digit
const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; // Uppercase
const noun = nouns[Math.floor(Math.random() * nouns.length)]; // Uppercase
const num = Math.floor(Math.random() * 90) + 10; // 2 digits
const lower = 'abc'; // Lowercase
return `${adj}${noun}${num}${lower}`; // E.g., "BoldWolf72abc" (14 chars)
}
```
2. **Send password to user** (security risk, consider alternative):
Include password in confirmation email (only for TEMP users, one-time):
```
Your temporary account has been created.
Email: {{email}}
Password: {{password}}
Please change your password after logging in.
```
**Better Alternative**: Use passwordless login link:
```
Click here to confirm your shift and access your account:
https://app.cmlite.org/confirm-shift/{{signupToken}}
```
## Performance Considerations
### Shift Query Optimization
**Index Upcoming Shifts:**
Create composite index for common query:
```sql
CREATE INDEX idx_shifts_upcoming ON "Shift" (date, "isPublic", status)
WHERE date >= CURRENT_DATE;
```
**Efficient Public Query:**
```typescript
// Only query future public shifts
const shifts = await prisma.shift.findMany({
where: {
isPublic: true,
date: { gte: new Date() },
status: { not: ShiftStatus.CANCELLED },
},
orderBy: { date: 'asc' },
include: {
cut: { select: { id: true, name: true } },
_count: {
select: { signups: { where: { status: SignupStatus.CONFIRMED } } },
},
},
});
```
### Email Queue Performance
**Batch Email Sending:**
Use BullMQ queue to avoid blocking API requests:
```typescript
// Add email jobs to queue
for (const volunteer of volunteers) {
await emailQueue.add('send-email', {
to: volunteer.email,
subject: 'Shift Update',
text: message,
});
}
// Worker processes jobs asynchronously
emailQueue.process('send-email', async (job) => {
await emailService.sendEmail(job.data);
});
```
### Concurrent Signup Handling
**Prevent Race Conditions:**
Use database transactions with `SELECT FOR UPDATE`:
```typescript
await prisma.$transaction(async (tx) => {
const shift = await tx.shift.findUnique({
where: { id: shiftId },
// Lock row to prevent concurrent updates
});
if (shift.currentVolunteers >= shift.maxVolunteers) {
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
}
// Create signup and update count atomically
await tx.shiftSignup.create({ data: signupData });
await tx.shift.update({
where: { id: shiftId },
data: { currentVolunteers: { increment: 1 } },
});
});
```
## Related Documentation
**Backend Modules:**
- [Shifts Backend Module](../../backend/modules/map/shifts.md) — API implementation
- [Email Service](../../backend/modules/services/email.md) — Confirmation emails
**Frontend Pages:**
- [ShiftsPage](../../frontend/pages/admin/shifts-page.md) — Admin CRUD interface
- [Public ShiftsPage](../../frontend/pages/public/shifts-page.md) — Public signup
- [VolunteerShiftsPage](../../frontend/pages/volunteer/shifts-page.md) — Volunteer assignments
**Database:**
- [Shift Model](../../database/models/map.md#shift-model) — Shift schema
- [ShiftSignup Model](../../database/models/map.md#shiftsignup-model) — Signup records
- [User Model](../../database/models/user.md) — TEMP user accounts
**Features:**
- [Cuts](./cuts.md) — Territory assignment for shifts
- [Canvassing](./canvassing.md) — Shift-based canvassing sessions
- [Users](../auth/users.md) — TEMP user management

View File

@@ -0,0 +1,409 @@
# 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)

File diff suppressed because it is too large Load Diff