28 KiB
Canvassing Session System
Overview
The canvassing system provides a complete door-to-door organizing workflow with GPS tracking, walking route optimization, visit recording, and progress tracking. It enables volunteers to efficiently canvass assigned territories using mobile devices with real-time location updates.
Key Capabilities:
- Session Lifecycle: ACTIVE → COMPLETED → ABANDONED (auto-close after 12h)
- Walking Route Algorithm: Nearest-neighbor optimization from volunteer GPS position
- Visit Recording: 7 outcome types with support level updates
- GPS Integration: Live tracking via TrackingSession (1:1 relationship)
- Rate Limiting: 30 visits/min per IP to prevent abuse
- Progress Tracking: Cut completion percentage auto-calculated
- Admin Oversight: Active sessions dashboard, activity feed, leaderboard
- Volunteer Portal: Full-screen map with bottom-sheet visit recording
Use Cases:
- Door-to-door canvassing for electoral campaigns
- Voter ID (identifying supporter levels)
- GOTV (Get Out The Vote) efforts
- Sign placement tracking
- Petition signature collection
- Issue surveys
- Volunteer coordination
Architecture
graph TD
A[Volunteer] -->|Start Session| B[VolunteerMapPage]
B -->|POST /api/map/canvass/sessions| C[Canvass Service]
C -->|Create| D[(CanvassSession)]
C -->|Start GPS| E[Tracking Service]
E -->|Create| F[(TrackingSession)]
B -->|Load Addresses| C
C -->|Filter by Cut| G[Spatial Utils]
G -->|Point-in-Polygon| H[(Location Model)]
B -->|Calculate Route| I[Walking Route Service]
I -->|Nearest Neighbor| J[Haversine Distance]
J -->|Return Route| B
K[Volunteer GPS] -->|Submit Points| E
E -->|Save| L[(TrackPoint)]
E -->|Calculate Distance| J
B -->|Record Visit| C
C -->|Create| M[(CanvassVisit)]
C -->|Update Address| N[(Address Model)]
C -->|Update Progress| D
O[Admin] -->|View Dashboard| P[CanvassDashboardPage]
P -->|GET /api/map/canvass/admin/activity| C
C -->|Aggregate Stats| D
C -->|Activity Feed| M
D -->|1:1| F
D -->|1:N| M
M -->|N:1| N
style D fill:#e1f5ff
style F fill:#e1f5ff
style M fill:#e1f5ff
style N fill:#e1f5ff
style H fill:#e1f5ff
Flow Description:
- Volunteer starts session → Creates CanvassSession + TrackingSession, loads addresses within cut
- Calculate route → Walking route service uses nearest-neighbor from volunteer GPS position
- GPS tracking → Auto-submit points every 10s, calculate distance with haversine
- Record visit → Create CanvassVisit with outcome, update Address support level, update session progress
- End session → Mark session COMPLETED, end tracking session, calculate final stats
- Admin oversight → View active sessions, activity feed, cut progress, volunteer leaderboard
Database Models
CanvassSession Model
See CanvassSession Model Documentation for full schema.
Key Fields:
userId: Foreign key to volunteer UsercutId: Foreign key to Cut (territory)shiftId: Optional foreign key to Shift (if started from shift)status: ACTIVE | COMPLETED | ABANDONEDstartedAt: Session start timestampendedAt: Session end timestamp (null while active)totalVisits: Count of CanvassVisit recordscompletionPercentage: Auto-calculated from cut progress
Status Lifecycle:
ACTIVE (session running)
↓ (volunteer ends session)
COMPLETED
OR
ACTIVE (session running > 12 hours)
↓ (auto-cleanup cron)
ABANDONED
CanvassVisit Model
See CanvassVisit Model Documentation for full schema.
Key Fields:
sessionId: Foreign key to CanvassSessionuserId: Foreign key to volunteer UseraddressId: Foreign key to Address (specific unit visited)outcome: Visit result (7 types)supportLevel: Updated support level (LEVEL_1-4 or null)signRequested: Boolean - resident wants lawn/window signnotes: Free-text canvass notesvisitedAt: Visit timestampdurationSeconds: Time spent at door (auto-calculated)
Visit Outcome Enum:
enum VisitOutcome {
NOT_HOME // Nobody answered door
REFUSED // Refused to speak
MOVED // Resident moved away
ALREADY_VOTED // Already voted (GOTV)
SPOKE_WITH // Had conversation
LEFT_LITERATURE // Left campaign material
COME_BACK_LATER // Asked to return later
}
Related Models:
- TrackingSession — GPS tracking (1:1)
- Address — Updated with visit data
- Cut — Territory boundary
- Shift — Optional shift assignment
API Endpoints
See Canvass Backend Module Documentation for full API reference.
Volunteer Endpoints:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/map/canvass/volunteer/assignments |
Any logged-in user | Get shifts with cut assignments |
| GET | /api/map/canvass/volunteer/stats |
Any logged-in user | Get volunteer canvass statistics |
| GET | /api/map/canvass/volunteer/visits |
Any logged-in user | List own canvass visits with pagination |
| POST | /api/map/canvass/sessions |
Any logged-in user | Start new canvass session |
| PATCH | /api/map/canvass/sessions/:id |
Any logged-in user | Update session (end session) |
| GET | /api/map/canvass/sessions/:id/addresses |
Any logged-in user | Get addresses within session cut |
| POST | /api/map/canvass/sessions/:id/route |
Any logged-in user | Calculate walking route |
| POST | /api/map/canvass/visits |
Any logged-in user | Record single visit |
| POST | /api/map/canvass/visits/bulk |
Any logged-in user | Record multiple visits (batch) |
| PATCH | /api/map/canvass/volunteer/locations/:id |
Any logged-in user | Update location from canvass |
Admin Endpoints:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/map/canvass/admin/activity |
MAP_ADMIN | Get recent canvass activity feed |
| GET | /api/map/canvass/admin/sessions |
MAP_ADMIN | List active canvass sessions |
| GET | /api/map/canvass/admin/visits |
MAP_ADMIN | List all canvass visits with filters |
| GET | /api/map/canvass/admin/progress |
MAP_ADMIN | Get cut completion progress |
| GET | /api/map/canvass/admin/leaderboard |
MAP_ADMIN | Get volunteer visit leaderboard |
Configuration
Environment Variables
| Variable | Type | Default | Description |
|---|---|---|---|
CANVASS_SESSION_TIMEOUT_HOURS |
number | 12 |
Auto-abandon active sessions after N hours |
CANVASS_VISIT_RATE_LIMIT |
number | 30 |
Max visits per minute per IP |
Rate Limiting
Visit Recording Rate Limit:
- Limit: 30 visits/min per IP address
- Window: 60 seconds
- Redis Prefix:
rl:canvass-visit: - Purpose: Prevent accidental bulk submissions from GPS auto-submit bugs
Session Auto-Cleanup
Abandoned Session Detection:
System automatically marks sessions as ABANDONED if:
- Status: ACTIVE
- Started: >12 hours ago
- No Activity: No visits in last hour
Cleanup Schedule:
- Startup: On API server startup (check all active sessions)
- Cron: Every hour at :00 (setInterval)
// api/src/server.ts
setInterval(async () => {
await canvassService.cleanupAbandonedSessions();
}, 60 * 60 * 1000); // 1 hour
Admin Workflow
Viewing Active Sessions
Step 1: Navigate to Canvass Dashboard
Navigate to Map → Canvass Dashboard in the admin sidebar.
![CanvassDashboardPage Screenshot Placeholder]
Step 2: View Active Sessions
Active Sessions card displays:
- Volunteer Name: Who is canvassing
- Cut Name: Territory being canvassed
- Start Time: When session started
- Duration: Time elapsed (live updating)
- Visits: Number of visits recorded
- Status: ACTIVE badge
Step 3: View Session Details
Click session row to view:
- Volunteer GPS Location: Last known position on map
- Route: Walking route polyline
- Visited Addresses: Green markers
- Unvisited Addresses: Blue markers
- Recent Visits: Last 10 visits with outcomes
Monitoring Canvass Activity
Step 1: View Activity Feed
Recent Activity section displays:
- Volunteer Name: Who recorded visit
- Address: Location visited
- Outcome: Visit result (icon + label)
- Support Level: Updated support level (color-coded)
- Time Ago: "5 minutes ago"
Step 2: Filter Activity
Use filters:
- Date Range: Last hour / day / week / month
- Outcome: Filter by specific outcome type
- Volunteer: Filter by volunteer name
- Cut: Filter by territory
Step 3: Export Activity
Click Export CSV to download activity feed for reporting.
Tracking Cut Completion
Step 1: View Cut Progress
Cut Progress card displays:
- Cut Name: Territory name
- Total Addresses: Count of addresses in cut
- Visited: Count of addresses with CanvassVisit records
- Completion: Percentage (progress bar)
- Last Activity: Time since last visit
Step 2: View Detailed Progress
Click cut row to view:
- Address List: All addresses in cut with visit status
- Visit Heatmap: Map showing visited (green) vs unvisited (blue)
- Outcome Breakdown: Pie chart of visit outcomes
- Volunteer Breakdown: Who visited which addresses
Volunteer Leaderboard
Step 1: View Leaderboard
Leaderboard card displays:
- Rank: 1st, 2nd, 3rd place
- Volunteer Name: Volunteer name
- Total Visits: Visit count
- Doors/Hour: Efficiency metric
- Top Outcome: Most common outcome
Step 2: Filter by Time Period
Toggle time period:
- Today: Visits since midnight
- This Week: Visits since Monday
- This Month: Visits since 1st of month
- All Time: Total visits
Volunteer Workflow
Starting a Canvass Session
Step 1: Login
Login at /login with volunteer account (or use TEMP account from shift signup).
Step 2: View Assignments
Navigate to Volunteer → My Assignments.
Step 3: Select Shift
Click Start Canvass on a shift with cut assignment.
Step 4: Grant GPS Permission
Browser requests geolocation permission. Click Allow.
Step 5: Start Session
System redirects to /volunteer/canvass/:cutId (full-screen map).
System will:
- Create CanvassSession (status=ACTIVE)
- Create TrackingSession (linked 1:1)
- Load addresses within cut polygon
- Calculate walking route from current GPS position
- Start GPS auto-tracking (submit points every 10s)
Following Walking Route
Step 1: View Route on Map
Map displays:
- Blue Polyline: Optimized walking route
- Blue Markers: Unvisited addresses (ordered by route)
- Green Markers: Visited addresses
- Red Marker: Current GPS position (live updating)
Step 2: Navigate to First Address
Follow route to nearest unvisited address. Route recalculates when you move.
Step 3: View Address Details
Tap marker to view:
- Address: Street address + unit number
- Resident Name: First/Last name (if available)
- Support Level: Previous support level (if available)
- Last Visit: Previous visit outcome + date (if applicable)
Recording a Visit
Step 1: Knock on Door
Approach address and knock/ring doorbell.
Step 2: Open Visit Recording Form
Tap Record Visit button in bottom toolbar. Bottom sheet slides up.
Step 3: Select Outcome
Choose visit outcome:
- Not Home: Nobody answered
- Refused: Refused to speak
- Moved: Resident moved away
- Already Voted: Already voted (GOTV campaigns)
- Spoke With: Had conversation
- Left Literature: Left campaign material
- Come Back Later: Asked to return later
Step 4: Update Support Level (if applicable)
For "Spoke With" outcome, select support level:
- Level 1 (Strong): Green badge
- Level 2 (Leaning): Yellow badge
- Level 3 (Undecided): Gray badge
- Level 4 (Opposed): Red badge
Step 5: Sign Request (optional)
Toggle Sign Requested if resident wants lawn/window sign.
Step 6: Add Notes (optional)
Enter free-text notes (e.g., "Asked about healthcare policy", "Concerned about taxes").
Step 7: Save Visit
Tap Save Visit. System will:
- Create CanvassVisit record with outcome + timestamp
- Update Address with new support level + sign status + notes
- Increment session.totalVisits count
- Update cut.completionPercentage
- Create LocationHistory audit record
- Submit GPS trackpoint with eventType=VISIT_RECORDED
- Update marker to green (visited)
- Recalculate walking route (exclude visited address)
Ending a Canvass Session
Step 1: Finish Route
Complete visits for all addresses (or as many as possible).
Step 2: End Session
Tap End Session button in header.
Step 3: Confirm
Confirmation modal displays session summary:
- Duration: Total session time
- Visits: Number of visits recorded
- Distance: Total distance walked (from GPS tracking)
- Doors/Hour: Efficiency metric
Step 4: Submit
Tap End Session. System will:
- Update CanvassSession (status=COMPLETED, endedAt=now)
- End TrackingSession (isActive=false, endedAt=now)
- Calculate final stats (totalVisits, totalDistanceM)
- Redirect to
/volunteer/activity(visit history page)
Viewing Visit History
Step 1: Navigate to My Activity
Navigate to Volunteer → My Activity.
Step 2: View Visit List
Table displays:
- Address: Location visited
- Outcome: Visit result (icon + label)
- Support Level: Updated support level
- Visit Date: Formatted date/time
- Notes: Canvassing notes (truncated)
Step 3: Filter Visits
Use filters:
- Date Range: Last week / month / all time
- Outcome: Filter by specific outcome
- Support Level: Filter by support level
Step 4: View Session History
Navigate to My Routes to view:
- Session List: Past canvass sessions
- Session Map: GPS route polyline + visited markers
- Session Stats: Duration, visits, distance, doors/hour
Code Examples
Start Canvass Session (Backend)
// api/src/modules/map/canvass/canvass.service.ts
async startSession(userId: string, data: StartSessionInput) {
const { cutId, shiftId, startLat, startLng } = data;
// Check for existing active session
const existing = await prisma.canvassSession.findFirst({
where: { userId, status: CanvassSessionStatus.ACTIVE },
});
if (existing) {
throw new AppError(400, 'Already have an active session', 'SESSION_ACTIVE');
}
// Create session + tracking session in transaction
const session = await prisma.$transaction(async (tx) => {
const canvassSession = await tx.canvassSession.create({
data: {
userId,
cutId,
shiftId,
status: CanvassSessionStatus.ACTIVE,
},
});
if (startLat && startLng) {
await tx.trackingSession.create({
data: {
userId,
canvassSessionId: canvassSession.id,
lastLatitude: new Prisma.Decimal(startLat),
lastLongitude: new Prisma.Decimal(startLng),
lastRecordedAt: new Date(),
},
});
}
return canvassSession;
});
setActiveCanvassSessions(
await prisma.canvassSession.count({
where: { status: CanvassSessionStatus.ACTIVE },
})
);
return session;
}
Calculate Walking Route (Backend)
// api/src/modules/map/canvass/canvass-route.service.ts
export function calculateWalkingRoute(
locations: RouteLocation[],
startLat?: number,
startLng?: number,
cutGeojson?: string,
): RouteResult {
if (locations.length === 0) {
return { orderedLocations: [], totalDistanceMeters: 0, estimatedMinutes: 0 };
}
// Determine starting point
let currentLat: number;
let currentLng: number;
if (startLat !== undefined && startLng !== undefined) {
currentLat = startLat;
currentLng = startLng;
} else if (cutGeojson) {
const polygons = parseGeoJsonPolygon(cutGeojson);
const centroid = calculateCentroid(polygons[0]!);
currentLat = centroid.lat;
currentLng = centroid.lng;
} else {
// Use first location as starting point
currentLat = locations[0]!.latitude;
currentLng = locations[0]!.longitude;
}
const remaining = [...locations];
const ordered: RouteLocation[] = [];
let totalDistance = 0;
// Nearest-neighbor algorithm
while (remaining.length > 0) {
let nearestIdx = 0;
let nearestDist = Infinity;
for (let i = 0; i < remaining.length; i++) {
const loc = remaining[i]!;
const dist = haversineDistance(currentLat, currentLng, loc.latitude, loc.longitude);
if (dist < nearestDist) {
nearestDist = dist;
nearestIdx = i;
}
}
const nearest = remaining.splice(nearestIdx, 1)[0]!;
ordered.push(nearest);
totalDistance += nearestDist;
currentLat = nearest.latitude;
currentLng = nearest.longitude;
}
const WALKING_SPEED_MPS = 5000 / 60; // 5 km/h in m/min
const MINUTES_PER_DOOR = 2;
const walkingMinutes = totalDistance / WALKING_SPEED_MPS;
const doorMinutes = ordered.length * MINUTES_PER_DOOR;
const estimatedMinutes = Math.round(walkingMinutes + doorMinutes);
return {
orderedLocations: ordered,
totalDistanceMeters: Math.round(totalDistance),
estimatedMinutes,
};
}
Record Visit (Backend)
// api/src/modules/map/canvass/canvass.service.ts
async recordVisit(userId: string, data: RecordVisitInput) {
const { sessionId, addressId, outcome, supportLevel, signRequested, notes } = data;
// Verify session ownership and active status
const session = await prisma.canvassSession.findFirst({
where: { id: sessionId, userId, status: CanvassSessionStatus.ACTIVE },
});
if (!session) {
throw new AppError(404, 'Active session not found', 'SESSION_NOT_FOUND');
}
// Create visit + update address in transaction
const visit = await prisma.$transaction(async (tx) => {
const canvassVisit = await tx.canvassVisit.create({
data: {
sessionId,
userId,
addressId,
outcome,
supportLevel,
signRequested: signRequested ?? false,
notes,
},
});
// Update address with new data
if (supportLevel || signRequested !== undefined || notes) {
await tx.address.update({
where: { id: addressId },
data: {
...(supportLevel && { supportLevel }),
...(signRequested !== undefined && { sign: signRequested }),
...(notes && { notes }),
},
});
}
// Increment session visit count
await tx.canvassSession.update({
where: { id: sessionId },
data: { totalVisits: { increment: 1 } },
});
// Update cut completion percentage
if (session.cutId) {
const cutId = session.cutId;
const totalAddresses = await tx.address.count({
where: {
location: {
// Point-in-polygon query omitted for brevity
},
},
});
const visitedAddresses = await tx.canvassVisit.count({
where: {
session: { cutId },
},
});
const completionPercentage = Math.round((visitedAddresses / totalAddresses) * 100);
await tx.cut.update({
where: { id: cutId },
data: { completionPercentage },
});
}
return canvassVisit;
});
recordCanvassVisit(outcome);
return visit;
}
GPS Auto-Tracking (Frontend)
// admin/src/components/canvass/GPSTracker.tsx
useEffect(() => {
if (!session || !geolocationEnabled) return;
const watchId = navigator.geolocation.watchPosition(
(position) => {
const point = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
recordedAt: new Date().toISOString(),
};
// Add to local buffer
setPointsBuffer((prev) => [...prev, point]);
// Update current position
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,
});
setPointsBuffer([]);
} catch (error) {
console.error('Failed to submit GPS points:', error);
}
}, 10000);
return () => {
navigator.geolocation.clearWatch(watchId);
clearInterval(interval);
};
}, [session, geolocationEnabled, pointsBuffer, trackingSessionId]);
Visit Recording Form (Frontend)
// admin/src/components/canvass/VisitRecordingForm.tsx
const handleSubmit = async (values: any) => {
try {
await api.post('/map/canvass/visits', {
sessionId: session.id,
addressId: selectedAddress.id,
outcome: values.outcome,
supportLevel: values.supportLevel,
signRequested: values.signRequested,
notes: values.notes,
});
message.success('Visit recorded');
form.resetFields();
onVisitRecorded();
} catch (error) {
message.error('Failed to record visit');
}
};
Troubleshooting
Issue: Walking Route Not Optimal
Symptoms:
- Route backtracks frequently
- Total distance much longer than expected
- Route doesn't start from volunteer GPS position
Causes:
- Nearest-neighbor algorithm is greedy (not globally optimal)
- Starting position not provided (defaults to cut centroid)
- GPS accuracy poor (volunteer position inaccurate)
Solutions:
- Use volunteer GPS position as start:
// Always pass volunteer GPS position to route calculation
const route = await calculateWalkingRoute(
locations,
currentLat,
currentLng,
cut.geojson
);
- Consider alternative algorithms:
For better optimization, use 2-opt or genetic algorithms (computationally expensive):
// Install optimization library
npm install routing-js
// Use 2-opt algorithm
import { twoOpt } from 'routing-js';
const optimized = twoOpt(locations, distanceMatrix);
- Pre-optimize routes for shifts:
Admin can pre-calculate optimal routes and assign to volunteers:
// Calculate route once, store in Shift model
const route = await calculateWalkingRoute(locations);
await prisma.shift.update({
where: { id: shiftId },
data: { preCalculatedRoute: JSON.stringify(route) },
});
Issue: Session Auto-Abandoned Prematurely
Symptoms:
- Active session marked ABANDONED while volunteer still canvassing
- Session timeout after <12 hours
- Volunteer can't record visits after timeout
Causes:
CANVASS_SESSION_TIMEOUT_HOURSset too low- Volunteer paused for lunch/break (no activity for >1 hour)
- System clock drift
Solutions:
- Increase timeout:
# In .env
CANVASS_SESSION_TIMEOUT_HOURS=24 # Was 12, increase to 24
- Record "heartbeat" visits:
Add periodic "still active" ping to prevent timeout:
// Volunteer app sends heartbeat every 30 minutes
setInterval(async () => {
await api.post(`/map/canvass/sessions/${sessionId}/heartbeat`);
}, 30 * 60 * 1000);
- Allow session resumption:
Let volunteers resume ABANDONED sessions:
// Backend: Add resume endpoint
async resumeSession(userId: string, sessionId: string) {
const session = await prisma.canvassSession.findFirst({
where: { id: sessionId, userId, status: CanvassSessionStatus.ABANDONED },
});
if (!session) {
throw new AppError(404, 'Abandoned session not found', 'SESSION_NOT_FOUND');
}
return prisma.canvassSession.update({
where: { id: sessionId },
data: { status: CanvassSessionStatus.ACTIVE },
});
}
Issue: GPS Tracking Draining Battery
Symptoms:
- Volunteer phone battery drains rapidly
- Phone overheats during canvassing
- GPS tracking fails after 2-3 hours
Causes:
enableHighAccuracyuses GPS + WiFi + cellular (power-hungry)- Watchposition submits too frequently (every second)
- Screen stays on during entire session
Solutions:
- Reduce GPS accuracy:
navigator.geolocation.watchPosition(
callback,
errorCallback,
{
enableHighAccuracy: false, // Use WiFi/cellular only (less accurate but lower power)
maximumAge: 5000, // Cache position for 5s
timeout: 30000, // Longer timeout
}
);
- Reduce submission frequency:
// Submit GPS points every 30s instead of 10s
const SUBMIT_INTERVAL_MS = 30000; // Was 10000
- Pause tracking during breaks:
Add "Pause Tracking" button to stop GPS watchPosition:
const pauseTracking = () => {
navigator.geolocation.clearWatch(watchId);
setTrackingPaused(true);
};
const resumeTracking = () => {
// Start watchPosition again
setTrackingPaused(false);
};
Performance Considerations
Visit Recording Rate Limiting
Prevent Abuse:
Rate limit prevents accidental bulk submissions:
// api/src/middleware/rate-limit.ts
const canvassVisitLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'rl:canvass-visit:',
points: 30, // 30 visits
duration: 60, // per 60 seconds
blockDuration: 300, // block for 5 minutes if exceeded
});
Legitimate Use Cases:
- Bulk data entry: Admin can bypass rate limit for importing historical data
- Offline sync: Mobile app queues visits offline, submits when online (batch endpoint)
Session Cleanup Performance
Efficient Abandoned Session Query:
-- Index for abandoned session cleanup
CREATE INDEX idx_canvass_sessions_abandoned ON "CanvassSession" ("status", "startedAt")
WHERE status = 'ACTIVE';
-- Efficient query
SELECT id FROM "CanvassSession"
WHERE status = 'ACTIVE'
AND "startedAt" < NOW() - INTERVAL '12 hours';
Cut Completion Calculation
Avoid N+1 Queries:
// Inefficient: query per cut
for (const cut of cuts) {
const visited = await prisma.canvassVisit.count({
where: { session: { cutId: cut.id } },
});
const total = await getAddressesInCut(cut.id).length;
cut.completionPercentage = (visited / total) * 100;
}
// Efficient: single aggregation query
const completionStats = await prisma.canvassSession.groupBy({
by: ['cutId'],
where: { status: CanvassSessionStatus.COMPLETED },
_count: { visits: true },
});
Related Documentation
Backend Modules:
- Canvass Backend Module — API implementation
- Walking Route Service — Route optimization
- Tracking Service — GPS tracking
Frontend Pages:
- VolunteerMapPage — Full-screen canvass map
- CanvassDashboardPage — Admin oversight
- MyActivityPage — Visit history
Database:
- CanvassSession Model — Session schema
- CanvassVisit Model — Visit records
- TrackingSession Model — GPS tracking
Features: