1124 lines
28 KiB
Markdown

# Canvass Module
## Overview
The Canvass module powers the volunteer canvassing system, enabling door-to-door outreach with GPS tracking, visit recording, walking route optimization, and real-time progress monitoring. It features role-based permissions, automated session management, and comprehensive analytics for campaign organizers.
**Key Features:**
- Canvass session management (start, end, abandon detection)
- Visit recording with outcomes (CONTACTED, SUPPORTER, NOT_HOME, REFUSED, etc.)
- Bulk visit recording (mark entire building as NOT_HOME)
- Walking route optimization (nearest-neighbor algorithm)
- GPS-enabled location tracking
- Role-gated field editing (volunteers update support data, admins update PII)
- Real-time cut completion percentage calculation
- Admin dashboard (stats, activity feed, volunteer leaderboard)
- Shift-based assignments (volunteers assigned to cuts via shifts)
- Rate limiting (30 visits/min per IP, 10 bulk visits/min)
- Abandoned session cleanup (ACTIVE > 12h → ABANDONED)
## File Paths
| File | Purpose |
|------|---------|
| `api/src/modules/map/canvass/canvass.routes.ts` | 2 routers (volunteer + admin) with 22 endpoints |
| `api/src/modules/map/canvass/canvass.service.ts` | Canvass business logic + session management |
| `api/src/modules/map/canvass/canvass.schemas.ts` | Zod validation schemas |
| `api/src/modules/map/canvass/canvass-route.service.ts` | Walking route optimization algorithm |
## Database Models
### CanvassSession
```prisma
model CanvassSession {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
cutId String
cut Cut @relation(fields: [cutId], references: [id], onDelete: Cascade)
shiftId String?
shift Shift? @relation(fields: [shiftId], references: [id], onDelete: SetNull)
status CanvassSessionStatus @default(ACTIVE)
startLatitude Float?
startLongitude Float?
startedAt DateTime @default(now())
endedAt DateTime?
visits CanvassVisit[]
@@index([userId])
@@index([cutId])
@@index([status])
@@map("canvass_sessions")
}
enum CanvassSessionStatus {
ACTIVE // Currently canvassing
COMPLETED // Ended by volunteer
ABANDONED // Auto-closed after 12h
}
```
### CanvassVisit
```prisma
model CanvassVisit {
id String @id @default(cuid())
addressId String // Changed from locationId to support multi-unit buildings
address Address @relation(fields: [addressId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
sessionId String?
session CanvassSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
shiftId String?
shift Shift? @relation(fields: [shiftId], references: [id], onDelete: SetNull)
outcome VisitOutcome
supportLevel SupportLevel?
signRequested Boolean @default(false)
signSize String?
notes String? @db.Text
durationSeconds Int?
visitedAt DateTime @default(now())
@@index([addressId])
@@index([userId])
@@index([sessionId])
@@index([outcome])
@@map("canvass_visits")
}
enum VisitOutcome {
CONTACTED // Successful conversation
SUPPORTER // Supporter identified
NOT_HOME // No answer
REFUSED // Declined conversation
MOVED // No longer at address
WRONG_ADDRESS // Address doesn't exist
CALLBACK // Requested follow-up
INACCESSIBLE // Cannot access (locked building, no entry)
}
```
### Address Model (Multi-Unit Support)
```prisma
model Address {
id String @id @default(cuid())
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
unitNumber String?
firstName String?
lastName String?
email String?
phone String?
supportLevel SupportLevel?
sign Boolean @default(false)
signSize String?
notes String? @db.Text
visits CanvassVisit[]
@@index([locationId])
@@map("addresses")
}
```
**Multi-Unit Building Support:**
- `Location` — Physical building (lat/lng, address, buildingNotes)
- `Address` — Individual unit within building (unitNumber, firstName, lastName, supportLevel, etc.)
- `CanvassVisit` — Links to `Address` (not `Location`) for per-unit tracking
---
## API Endpoints
### Volunteer Endpoints (Authentication Required, Any Role)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/canvass/my/assignments` | Get assigned shifts with cuts |
| GET | `/api/map/canvass/my/stats` | Get volunteer statistics |
| GET | `/api/map/canvass/my/visits` | List my visit history (paginated) |
| GET | `/api/map/canvass/my/session` | Get active canvass session |
| POST | `/api/map/canvass/sessions` | Start new canvass session |
| POST | `/api/map/canvass/sessions/:id/end` | End canvass session |
| GET | `/api/map/canvass/cuts/:cutId/locations` | Get locations in cut for canvassing |
| GET | `/api/map/canvass/cuts/:cutId/route` | Get optimized walking route |
| GET | `/api/map/canvass/locations` | Get all locations with visit annotations |
| PUT | `/api/map/canvass/locations/:id` | Update location (role-gated fields) |
| POST | `/api/map/canvass/locations` | Create location (role-gated fields) |
| POST | `/api/map/canvass/reverse-geocode` | Reverse geocode lat/lng |
| POST | `/api/map/canvass/geocode-search` | Geocode address for map search |
| POST | `/api/map/canvass/visits` | Record visit (rate-limited: 30/min) |
| POST | `/api/map/canvass/visits/bulk` | Bulk record visits for building (rate-limited: 10/min) |
### Admin Endpoints (Authentication Required, MAP_ADMIN Roles)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/map/canvass/stats` | Get admin statistics |
| GET | `/api/map/canvass/stats/cuts/:cutId` | Get cut-specific statistics |
| GET | `/api/map/canvass/activity` | Get recent activity feed (paginated) |
| GET | `/api/map/canvass/volunteers` | List volunteers with visit counts |
| GET | `/api/map/canvass/volunteers/:userId` | Get volunteer statistics |
| GET | `/api/map/canvass/visits` | List all visits (paginated, filtered) |
**Admin Roles:** `SUPER_ADMIN`, `MAP_ADMIN`
---
## Volunteer Endpoint Details
### POST /api/map/canvass/sessions
Start new canvass session for a cut.
**Request Body:**
```json
{
"cutId": "clxCut123",
"shiftId": "clxShift456",
"startLatitude": 43.6532,
"startLongitude": -79.3832
}
```
**Response (201 Created):**
```json
{
"id": "clxSession789",
"userId": "clxUser123",
"cutId": "clxCut123",
"shiftId": "clxShift456",
"status": "ACTIVE",
"startLatitude": 43.6532,
"startLongitude": -79.3832,
"startedAt": "2026-02-11T14:00:00.000Z",
"endedAt": null,
"cut": {
"id": "clxCut123",
"name": "Downtown Ward 5"
},
"shift": {
"id": "clxShift456",
"title": "Saturday Canvass"
}
}
```
**Validation:**
- Only one active session per user allowed
- Cut must exist
- Shift is optional (can canvass outside scheduled shifts)
**Error Responses:**
- `409 Conflict`: User already has active session
- `404 Not Found`: Cut not found
---
### POST /api/map/canvass/sessions/:id/end
End active canvass session.
**Path Parameters:**
- `id` (string): Session ID
**Response (200 OK):**
Returns updated session with `status: COMPLETED` and `endedAt` timestamp.
**Post-Processing:**
- Recalculates cut completion percentage
- Updates Prometheus metrics (active sessions gauge)
**Validation:**
- Session must belong to authenticated user
- Session must be ACTIVE (not already completed/abandoned)
---
### GET /api/map/canvass/my/assignments
Get volunteer's assigned shifts with associated cuts.
**Example Response (200 OK):**
```json
[
{
"shiftId": "clxShift456",
"shiftTitle": "Saturday Canvass",
"shiftDate": "2026-02-15",
"startTime": "10:00",
"endTime": "14:00",
"location": "Community Center, 123 Main St",
"cutId": "clxCut123",
"cutName": "Downtown Ward 5",
"completionPercentage": 42
}
]
```
**Filtering:**
- Only returns confirmed signups (`status: CONFIRMED`)
- Only returns shifts with associated cuts (`cutId` not null)
- Ordered by shift date ascending (upcoming shifts first)
---
### GET /api/map/canvass/cuts/:cutId/locations
Get locations within cut for canvassing with visit annotations.
**Path Parameters:**
- `cutId` (string): Cut ID
**Query Parameters:**
- `minLat`, `maxLat`, `minLng`, `maxLng` (optional): Bounding box for visible map area
**Example Response (200 OK):**
```json
[
{
"id": "clxAddress123",
"unitNumber": "Apt 4",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phone": "416-555-1234",
"supportLevel": "LEVEL_1",
"sign": true,
"signSize": "Large",
"notes": "Willing to volunteer",
"location": {
"id": "clxLocation456",
"latitude": 43.6532,
"longitude": -79.3832,
"address": "123 Main St, Toronto, ON",
"buildingNotes": "Intercom code: 1234"
},
"lastVisit": {
"outcome": "CONTACTED",
"visitedAt": "2026-02-10T14:30:00.000Z",
"visitorName": "Jane Smith",
"isMyVisit": false
}
}
]
```
**Two-Stage Filtering:**
1. **Database bounds filter** — Fast WHERE clause on lat/lng
2. **Polygon filter** — In-memory point-in-polygon check
**Visit Annotations:**
- `lastVisit` — Most recent visit to this address (any volunteer)
- `isMyVisit` — True if authenticated user made last visit
- Null if address never visited
---
### GET /api/map/canvass/cuts/:cutId/route
Get optimized walking route for cut.
**Path Parameters:**
- `cutId` (string): Cut ID
**Query Parameters:**
- `excludeVisited` (boolean, default: false): Exclude already-visited addresses
- `startLatitude` (number, optional): Starting position latitude
- `startLongitude` (number, optional): Starting position longitude
**Example Response (200 OK):**
```json
{
"route": [
{
"id": "clxAddress123",
"latitude": 43.6532,
"longitude": -79.3832,
"address": "123 Main St",
"unitNumber": "Apt 4",
"distanceFromPrevious": 0
},
{
"id": "clxAddress124",
"latitude": 43.6540,
"longitude": -79.3825,
"address": "125 Main St",
"unitNumber": null,
"distanceFromPrevious": 92.3
}
],
"totalDistance": 1847.6,
"estimatedDuration": 1680
}
```
**Walking Route Algorithm:**
Nearest-neighbor greedy algorithm:
```typescript
// Start at provided coordinates or first location
let current = startCoords || locations[0];
const route: RouteStop[] = [];
while (unvisited.length > 0) {
// Find nearest unvisited location
const nearest = findNearest(current, unvisited);
const distance = haversineDistance(current, nearest);
route.push({
...nearest,
distanceFromPrevious: distance,
});
current = nearest;
unvisited = unvisited.filter(loc => loc.id !== nearest.id);
}
// Calculate total distance and duration
const totalDistance = route.reduce((sum, stop) => sum + stop.distanceFromPrevious, 0);
const estimatedDuration = Math.ceil(totalDistance / WALKING_SPEED_MPS); // 1.4 m/s
```
**Performance:**
- O(n²) complexity (acceptable for typical cut sizes <500 locations)
- Uses haversine distance (meters) for accurate walking distances
- Assumes walking speed: 1.4 m/s (5 km/h)
---
### POST /api/map/canvass/visits
Record visit to an address.
**Rate Limiting:** 30 requests per minute per IP
**Request Body:**
```json
{
"addressId": "clxAddress123",
"outcome": "CONTACTED",
"supportLevel": "LEVEL_2",
"signRequested": true,
"signSize": "Large",
"notes": "Interested in volunteering for phone banks",
"durationSeconds": 180,
"sessionId": "clxSession789",
"shiftId": "clxShift456",
"updateLocation": true
}
```
**Field Descriptions:**
- `addressId` (required): Address ID (unit within building)
- `outcome` (required): Visit outcome enum
- `supportLevel` (optional): Support level identified during visit
- `signRequested` (optional, default: false): Lawn sign requested
- `signSize` (optional): Sign size if requested
- `notes` (optional): Visit notes
- `durationSeconds` (optional): Time spent at door
- `sessionId` (optional): Active canvass session ID
- `shiftId` (optional): Associated shift ID
- `updateLocation` (optional, default: true): Update address record with visit data
**Response (201 Created):**
Returns created visit object.
**Address Update Logic:**
If `updateLocation=true` and outcome is `CONTACTED` or `SUPPORTER`:
```typescript
await prisma.address.update({
where: { id: addressId },
data: {
supportLevel: data.supportLevel || undefined,
sign: data.signRequested || undefined,
signSize: data.signRequested ? data.signSize : undefined,
},
});
```
**Metrics:**
- Increments `cm_canvass_visits_total` counter with outcome label
- Updates cut completion percentage
---
### POST /api/map/canvass/visits/bulk
Record visit to all unvisited units in a building.
**Rate Limiting:** 10 requests per minute per IP (stricter than single visits)
**Request Body:**
```json
{
"locationId": "clxLocation456",
"outcome": "NOT_HOME",
"notes": "Building-wide: No answer at any unit",
"sessionId": "clxSession789",
"shiftId": "clxShift456"
}
```
**Allowed Outcomes:**
Only non-contact outcomes:
- `NOT_HOME`
- `REFUSED`
- `MOVED`
**Logic:**
1. Find all addresses at location (building)
2. Filter to unvisited addresses (no existing visit records)
3. Create visit records for all unvisited addresses in bulk
**Response (201 Created):**
```json
{
"created": 8,
"skipped": 2,
"locationId": "clxLocation456"
}
```
**Use Cases:**
- Large apartment buildings where no one answers buzzer
- Entire building marked as MOVED (demolished/vacant)
- Save time: record 10+ units with single action
---
### PUT /api/map/canvass/locations/:id
Update location with role-gated field restrictions.
**Path Parameters:**
- `id` (string): Address ID
**Request Body (Volunteer):**
```json
{
"supportLevel": "LEVEL_2",
"sign": true,
"signSize": "Large",
"notes": "Willing to volunteer"
}
```
**Request Body (Admin):**
```json
{
"firstName": "John",
"lastName": "Doe",
"address": "123 Main St, Unit 4",
"unitNumber": "4",
"email": "john@example.com",
"phone": "416-555-1234",
"supportLevel": "LEVEL_2",
"sign": true
}
```
**Role-Gated Fields:**
**All Authenticated Users:**
- `supportLevel`
- `sign`
- `signSize`
- `notes`
**Admins Only** (`SUPER_ADMIN`, `MAP_ADMIN`):
- `firstName`
- `lastName`
- `address`
- `unitNumber`
- `email`
- `phone`
**TEMP Users:**
- Cannot update any fields (read-only canvassing)
**Service-Level Field Stripping:**
```typescript
const isAdmin = role === UserRole.SUPER_ADMIN || role === UserRole.MAP_ADMIN;
const isTemp = role === UserRole.TEMP;
if (isTemp) {
throw new AppError(403, 'TEMP users cannot edit locations', 'FORBIDDEN');
}
const updateData: Prisma.AddressUpdateInput = {};
// Volunteer fields (all authenticated users)
if (data.supportLevel !== undefined) updateData.supportLevel = data.supportLevel;
if (data.sign !== undefined) updateData.sign = data.sign;
if (data.signSize !== undefined) updateData.signSize = data.signSize;
if (data.notes !== undefined) updateData.notes = data.notes;
// Admin-only PII fields
if (isAdmin) {
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;
if (data.email !== undefined) updateData.email = data.email;
if (data.phone !== undefined) updateData.phone = data.phone;
}
```
---
## Admin Endpoint Details
### GET /api/map/canvass/stats
Get aggregate canvassing statistics.
**Example Response (200 OK):**
```json
{
"totalVisits": 3847,
"totalVolunteers": 42,
"activeSessions": 7,
"byOutcome": {
"CONTACTED": 1892,
"SUPPORTER": 542,
"NOT_HOME": 987,
"REFUSED": 234,
"MOVED": 89,
"WRONG_ADDRESS": 43,
"CALLBACK": 34,
"INACCESSIBLE": 26
},
"topVolunteers": [
{
"userId": "clxUser123",
"name": "Jane Smith",
"visitCount": 247
}
],
"cutProgress": [
{
"cutId": "clxCut123",
"cutName": "Downtown Ward 5",
"completionPercentage": 68,
"visitCount": 342,
"totalAddresses": 503
}
]
}
```
---
### GET /api/map/canvass/activity
Get recent canvass activity feed.
**Query Parameters:**
- `page` (default: 1): Page number
- `limit` (default: 20, max: 100): Results per page
- `cutId` (optional): Filter by cut
- `userId` (optional): Filter by volunteer
- `outcome` (optional): Filter by outcome
**Example Response (200 OK):**
```json
{
"activities": [
{
"id": "clxVisit789",
"userId": "clxUser123",
"user": {
"name": "Jane Smith",
"email": "jane@example.com"
},
"addressId": "clxAddress456",
"address": {
"address": "123 Main St",
"unitNumber": "Apt 4"
},
"outcome": "CONTACTED",
"supportLevel": "LEVEL_2",
"visitedAt": "2026-02-11T14:30:00.000Z",
"durationSeconds": 180
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 3847,
"totalPages": 193
}
}
```
---
### GET /api/map/canvass/volunteers
List volunteers with visit counts.
**Example Response (200 OK):**
```json
[
{
"userId": "clxUser123",
"name": "Jane Smith",
"email": "jane@example.com",
"totalVisits": 247,
"todayVisits": 18,
"activeSessions": 1
}
]
```
---
## Service Functions
### canvassService.startSession(userId, data)
Start new canvass session.
**Validation:**
```typescript
// Check for existing active session
const existing = await prisma.canvassSession.findFirst({
where: { userId, status: CanvassSessionStatus.ACTIVE },
});
if (existing) {
throw new AppError(409, 'You already have an active canvass session', 'SESSION_ACTIVE');
}
// Verify cut exists
const cut = await prisma.cut.findUnique({ where: { id: data.cutId } });
if (!cut) {
throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
}
```
---
### canvassService.endSession(sessionId, userId)
End canvass session and recalculate cut completion.
**Post-Processing:**
```typescript
// End session
await prisma.canvassSession.update({
where: { id: sessionId },
data: { status: CanvassSessionStatus.COMPLETED, endedAt: new Date() },
});
// Recalculate cut completion percentage
await this.recalculateCutCompletion(session.cutId);
```
**Cut Completion Calculation:**
```typescript
async recalculateCutCompletion(cutId: string) {
// Get all addresses in cut
const totalAddresses = await this.countAddressesInCut(cutId);
// Get visited addresses (distinct addressId from visits)
const visitedCount = await prisma.canvassVisit.findMany({
where: { address: { location: { cuts: { some: { id: cutId } } } } },
distinct: ['addressId'],
}).then(visits => visits.length);
const completionPercentage = totalAddresses > 0
? Math.round((visitedCount / totalAddresses) * 100)
: 0;
await prisma.cut.update({
where: { id: cutId },
data: { completionPercentage },
});
}
```
---
### canvassService.recordVisit(userId, data)
Record visit to address with optional location update.
**Address Update Logic:**
```typescript
if (data.updateLocation && (data.outcome === VisitOutcome.CONTACTED || data.outcome === VisitOutcome.SUPPORTER)) {
await prisma.address.update({
where: { id: data.addressId },
data: {
supportLevel: data.supportLevel || undefined,
sign: data.signRequested || undefined,
signSize: data.signRequested ? data.signSize : undefined,
},
});
}
```
**Metrics:**
```typescript
recordCanvassVisit(data.outcome); // Prometheus counter
```
---
### canvassService.getWalkingRoute(cutId, userId, options)
Get optimized walking route for cut.
**Algorithm:**
```typescript
import { calculateWalkingRoute } from './canvass-route.service';
const addresses = await this.getCutLocationsForCanvass(cutId, userId);
// Filter to unvisited if requested
const unvisited = options.excludeVisited
? addresses.filter(addr => !addr.lastVisit)
: addresses;
// Calculate route using nearest-neighbor algorithm
const route = calculateWalkingRoute(
unvisited,
options.startLatitude,
options.startLongitude,
);
return route;
```
---
## Abandoned Session Cleanup
**Scheduled Task:**
Runs on API startup and every hour:
```typescript
// api/src/server.ts
async function closeAbandonedSessions() {
const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
const result = await prisma.canvassSession.updateMany({
where: {
status: CanvassSessionStatus.ACTIVE,
startedAt: { lt: twelveHoursAgo },
},
data: {
status: CanvassSessionStatus.ABANDONED,
endedAt: new Date(),
},
});
if (result.count > 0) {
logger.info(`Closed ${result.count} abandoned canvass sessions`);
}
}
// Run on startup
closeAbandonedSessions();
// Run every hour
setInterval(closeAbandonedSessions, 60 * 60 * 1000);
```
---
## Validation Schemas
### Record Visit Schema
```typescript
export const recordVisitSchema = z.object({
addressId: z.string().min(1),
outcome: z.nativeEnum(VisitOutcome),
supportLevel: z.nativeEnum(SupportLevel).optional(),
signRequested: z.boolean().optional().default(false),
signSize: z.string().optional(),
notes: z.string().optional(),
durationSeconds: z.number().int().optional(),
sessionId: z.string().optional(),
shiftId: z.string().optional(),
updateLocation: z.boolean().optional().default(true),
});
```
### Bulk Record Visit Schema
```typescript
export const bulkRecordVisitSchema = z.object({
locationId: z.string().min(1), // Building ID
outcome: z.enum(['NOT_HOME', 'REFUSED', 'MOVED']), // Only non-contact outcomes
notes: z.string().optional(),
sessionId: z.string().optional(),
shiftId: z.string().optional(),
});
```
---
## Code Examples
### Volunteer: Start Canvass Session
```typescript
import { api } from '@/lib/api';
import { message } from 'antd';
const startSession = async (cutId: string, shiftId?: string) => {
// Get current GPS position
navigator.geolocation.getCurrentPosition(async (position) => {
try {
const { data } = await api.post('/api/map/canvass/sessions', {
cutId,
shiftId,
startLatitude: position.coords.latitude,
startLongitude: position.coords.longitude,
});
message.success('Canvass session started');
console.log(`Session ID: ${data.id}`);
} catch (error: any) {
if (error.response?.status === 409) {
message.error('You already have an active session');
} else {
message.error('Failed to start session');
}
}
});
};
```
### Volunteer: Record Visit
```typescript
import { api } from '@/lib/api';
import { message } from 'antd';
const recordVisit = async (addressId: string, outcome: string, sessionId: string) => {
try {
const { data } = await api.post('/api/map/canvass/visits', {
addressId,
outcome,
supportLevel: 'LEVEL_2',
signRequested: true,
signSize: 'Large',
notes: 'Interested in volunteering',
durationSeconds: 180,
sessionId,
updateLocation: true,
});
message.success('Visit recorded');
return data;
} catch (error: any) {
if (error.response?.status === 429) {
message.error('Rate limit exceeded. Please wait a moment.');
} else {
message.error('Failed to record visit');
}
}
};
```
### Admin: Get Canvass Statistics
```typescript
import { api } from '@/lib/api';
const getStats = async () => {
const { data } = await api.get('/api/map/canvass/stats');
console.log(`Total Visits: ${data.totalVisits}`);
console.log(`Active Sessions: ${data.activeSessions}`);
console.log(`Top Volunteer: ${data.topVolunteers[0]?.name} (${data.topVolunteers[0]?.visitCount} visits)`);
return data;
};
```
---
## Frontend Integration
### Volunteer Portal
**VolunteerMapPage** (`admin/src/pages/volunteer/VolunteerMapPage.tsx`):
- Full-screen Leaflet map (no AppLayout)
- GPS tracking (blue dot follows volunteer)
- Location markers (color-coded by visit status)
- Walking route visualization (dashed blue line)
- Bottom sheet toolbar (floating panel)
- Visit recording form (outcome, notes, duration)
- Optimized route toggle (exclude visited addresses)
- Session timer (displays elapsed time)
**MyAssignmentsPage** (`admin/src/pages/volunteer/MyAssignmentsPage.tsx`):
- Assigned shifts table
- Cut names + completion percentage
- "Start Canvassing" button (opens map, starts session)
**MyActivityPage** (`admin/src/pages/volunteer/MyActivityPage.tsx`):
- Visit history table (paginated)
- Outcome breakdown (pie chart)
- Today's visit count vs. total
**State Management:**
```typescript
// admin/src/stores/canvass.store.ts
interface CanvassState {
session: CanvassSession | null;
locations: CanvassLocation[];
route: WalkingRoute | null;
gpsPosition: { lat: number; lng: number } | null;
selectedAddress: string | null;
showVisitRecording: boolean;
}
```
### Admin Dashboard
**CanvassDashboardPage** (`admin/src/pages/CanvassDashboardPage.tsx`):
- Statistics cards (total visits, active sessions, volunteers, completion %)
- Recent activity feed (realtime visit stream)
- Cut progress table (completionPercentage, visitCount)
- Volunteer leaderboard (sorted by visit count)
---
## Performance Considerations
**Rate Limiting:**
- Single visits: 30/min per IP (prevents spam)
- Bulk visits: 10/min per IP (stricter for building-wide operations)
- Geocoding: 10/min per IP (prevents geocoding API abuse)
**Abandoned Session Cleanup:**
- Runs hourly (low overhead)
- Only updates sessions older than 12 hours
- Prevents stale ACTIVE sessions blocking new sessions
**Walking Route Algorithm:**
- O(n²) complexity acceptable for typical cuts (<500 locations)
- Uses haversine distance (meters) for accuracy
- Pre-filters visited addresses when `excludeVisited=true`
**Cut Completion Calculation:**
- Triggered on session end (not every visit)
- Uses `distinct: ['addressId']` to count unique addresses
- Caches result in `Cut.completionPercentage` field
---
## Troubleshooting
### Issue: "You already have an active canvass session"
**Cause:** Volunteer forgot to end previous session
**Solution:**
- Admin: Find session in CanvassDashboardPage, manually mark as COMPLETED
- Wait for automatic cleanup (12h timeout)
- Volunteer: Navigate to session end screen and click "End Session"
### Issue: Rate limit exceeded (429) when recording visits
**Cause:** Recording visits too quickly (>30/min)
**Solution:**
- Slow down visit recording (realistic door-knocking speed: ~10-15/hr)
- Use bulk visit endpoint for buildings (NOT_HOME for entire building)
### Issue: Walking route skips some addresses
**Cause:** `excludeVisited=true` filters out already-visited addresses
**Solution:**
- Set `excludeVisited=false` to see all addresses
- Verify addresses have visits recorded (check `lastVisit` field)
### Issue: Cut completion percentage not updating
**Cause:** Completion calculated on session end, not per-visit
**Solution:**
- End canvass session to trigger recalculation
- Admin: View cut stats to verify visitCount vs. totalAddresses
---
## Related Documentation
- [Shifts Module](/v2/backend/modules/shifts.md) - Shift CRUD + signup system
- [Cuts Module](/v2/backend/modules/cuts.md) - Polygon filtering
- [Locations Module](/v2/backend/modules/locations.md) - Location management
- [Spatial Utils](/v2/backend/utilities/spatial-utils.md) - Point-in-polygon, haversine distance
- [Frontend: VolunteerMapPage](/v2/frontend/pages/volunteer/volunteer-map-page.md) - Canvassing map UI
- [Frontend: CanvassDashboardPage](/v2/frontend/pages/admin/canvass-dashboard-page.md) - Admin dashboard
- [API Reference: Canvass](/v2/api-reference/canvass.md) - Complete endpoint reference
- [Feature: Volunteer Canvassing](/v2/features/map/volunteer-canvassing.md) - Canvassing feature guide