28 KiB

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

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

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)

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:

{
  "cutId": "clxCut123",
  "shiftId": "clxShift456",
  "startLatitude": 43.6532,
  "startLongitude": -79.3832
}

Response (201 Created):

{
  "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):

[
  {
    "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):

[
  {
    "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):

{
  "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:

// 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:

{
  "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:

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:

{
  "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):

{
  "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):

{
  "supportLevel": "LEVEL_2",
  "sign": true,
  "signSize": "Large",
  "notes": "Willing to volunteer"
}

Request Body (Admin):

{
  "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:

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

{
  "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):

{
  "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):

[
  {
    "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:

// 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:

// 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:

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:

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:

recordCanvassVisit(data.outcome); // Prometheus counter

canvassService.getWalkingRoute(cutId, userId, options)

Get optimized walking route for cut.

Algorithm:

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:

// 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

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

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

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

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

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:

// 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