58 KiB
Raw Blame History

Walk Sheets & QR Codes

Overview

The Walk Sheets system provides printable door-to-door canvassing materials with integrated QR code support. This feature enables campaign organizers to generate professional walk sheets for volunteers, complete with address lists, cut boundaries, and quick-access QR codes to campaign resources.

Key Features:

  • Browser-based printing (no server-side PDF generation)
  • Customizable headers, footers, and QR codes
  • Cut-based address filtering
  • Point-in-polygon location selection
  • Print-optimized layout (A4/Letter)
  • Cut export reports with statistics
  • Multi-unit building support
  • Support level indicators

Use Cases:

  • Door-to-door canvassing
  • Volunteer shift materials
  • Cut logistics planning
  • Campaign resource distribution
  • Field data collection

Architecture Highlights:

  • Frontend-only printing (window.print())
  • QR code generation via public API
  • MapSettings singleton for configuration
  • Point-in-polygon filtering for cut locations
  • CSS @media print rules for layout

Architecture

flowchart TB
    subgraph Admin Interface
        Admin[Admin User]
        Settings[MapSettingsPage]
        WalkSheet[WalkSheetPage]
        CutExport[CutExportPage]
    end

    subgraph API Layer
        MapSettingsAPI["/api/map-settings"]
        CutsAPI["/api/cuts/:id"]
        LocationsAPI["/api/locations?cutId="]
        QRAPI["/api/qr/generate"]
    end

    subgraph Database
        MapSettingsDB[(MapSettings)]
        CutsDB[(Cuts)]
        LocationsDB[(Locations)]
    end

    subgraph Print System
        Preview[Print Preview]
        Browser[Browser Print Dialog]
        PDF[PDF Output]
    end

    Admin --> Settings
    Admin --> WalkSheet
    Admin --> CutExport

    Settings --> MapSettingsAPI
    WalkSheet --> MapSettingsAPI
    WalkSheet --> CutsAPI
    WalkSheet --> LocationsAPI
    WalkSheet --> QRAPI
    CutExport --> CutsAPI
    CutExport --> LocationsAPI

    MapSettingsAPI --> MapSettingsDB
    CutsAPI --> CutsDB
    LocationsAPI --> LocationsDB

    WalkSheet --> Preview
    CutExport --> Preview
    Preview --> Browser
    Browser --> PDF

    QRAPI --> QRGen[QR Code PNG Generator]
    QRGen --> Base64[Base64 Data URL]
    Base64 --> WalkSheet

Data Flow:

  1. Configuration Phase:

    • Admin configures walk sheet settings (title, subtitle, footer, QR codes)
    • Settings stored in MapSettings singleton
    • QR code URLs and labels defined (up to 3)
  2. Generation Phase:

    • Admin selects cut from dropdown
    • Frontend fetches cut details and settings
    • Point-in-polygon filter retrieves locations within cut
    • QR codes generated via POST /api/qr/generate
    • Walk sheet rendered with all components
  3. Print Phase:

    • window.print() triggered
    • Browser print dialog opens
    • Print CSS rules applied (hide nav, adjust layout)
    • User selects printer or "Save as PDF"

Database Models

MapSettings Model

model MapSettings {
  id        Int      @id @default(1) // Singleton

  // Walk Sheet Configuration
  walkSheetTitle    String  @default("Walk Sheet")
  walkSheetSubtitle String  @default("")
  walkSheetFooter   String  @default("")

  // QR Code 1
  qrCode1Url   String?
  qrCode1Label String?

  // QR Code 2
  qrCode2Url   String?
  qrCode2Label String?

  // QR Code 3
  qrCode3Url   String?
  qrCode3Label String?

  // Other map settings
  defaultCenterLat Float   @default(43.6532)
  defaultCenterLng Float   @default(-79.3832)
  defaultZoom      Int     @default(12)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Singleton Pattern:

  • Always ID = 1
  • Created during seed if not exists
  • Single source of truth for walk sheet config

Cut Model

model Cut {
  id          Int      @id @default(autoincrement())
  name        String
  description String?
  geojson     Json     // GeoJSON Polygon or MultiPolygon
  color       String   @default("#3498db")
  visible     Boolean  @default(true)

  shifts      Shift[]

  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

GeoJSON Structure:

{
  "type": "Polygon",
  "coordinates": [
    [
      [-79.38, 43.65],
      [-79.37, 43.65],
      [-79.37, 43.66],
      [-79.38, 43.66],
      [-79.38, 43.65]
    ]
  ]
}

Location Model

model Location {
  id          Int      @id @default(autoincrement())
  address     String
  latitude    Float?
  longitude   Float?
  postalCode  String?
  province    String?

  // Geocoding metadata
  geocodeConfidence Int?        // 0-100
  geocodeProvider   String?     // GOOGLE, MAPBOX, etc.

  // NAR import fields
  locGuid           String?  @unique
  federalDistrict   String?
  buildingUse       Int?     // 1 = Residential

  addresses   Address[]

  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

Address Model

model Address {
  id         Int      @id @default(autoincrement())
  locationId Int
  location   Location @relation(fields: [locationId], references: [id], onDelete: Cascade)

  unitNumber   String?
  firstName    String?
  lastName     String?
  supportLevel Int?     // 1-5 scale
  notes        String?

  // NAR import
  addrGuid String? @unique

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([locationId])
}

Support Level Scale:

  • 1 = Strong Opposition
  • 2 = Lean Opposition
  • 3 = Undecided
  • 4 = Lean Support
  • 5 = Strong Support

API Endpoints

GET /api/map-settings

Fetch walk sheet configuration.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Response:

{
  "id": 1,
  "walkSheetTitle": "Toronto Canvass Walk Sheet",
  "walkSheetSubtitle": "Ward 10 - November 2025",
  "walkSheetFooter": "Questions? Call HQ at 416-555-1234",
  "qrCode1Url": "https://example.com/campaign",
  "qrCode1Label": "Campaign Page",
  "qrCode2Url": "https://example.com/volunteer",
  "qrCode2Label": "Volunteer Portal",
  "qrCode3Url": "https://example.com/donate",
  "qrCode3Label": "Donate Now",
  "defaultCenterLat": 43.6532,
  "defaultCenterLng": -79.3832,
  "defaultZoom": 12,
  "createdAt": "2025-01-15T10:00:00Z",
  "updatedAt": "2025-02-10T14:30:00Z"
}

PUT /api/map-settings

Update walk sheet configuration.

Authentication: Required (SUPER_ADMIN, MAP_ADMIN)

Request Body:

{
  "walkSheetTitle": "Updated Title",
  "walkSheetSubtitle": "Updated Subtitle",
  "walkSheetFooter": "Updated footer text with contact info",
  "qrCode1Url": "https://newurl.com",
  "qrCode1Label": "New Label"
}

Response: Updated MapSettings object

Validation:

  • walkSheetTitle: 1-100 characters
  • walkSheetSubtitle: 0-200 characters
  • walkSheetFooter: 0-500 characters
  • qrCode URLs: valid HTTP/HTTPS URLs
  • qrCode labels: 0-50 characters

GET /api/cuts/:id

Fetch cut details for walk sheet.

Authentication: Required

Response:

{
  "id": 42,
  "name": "Downtown Core",
  "description": "High-density residential area",
  "geojson": {
    "type": "Polygon",
    "coordinates": [[...]]
  },
  "color": "#3498db",
  "visible": true,
  "createdAt": "2025-01-20T09:00:00Z",
  "updatedAt": "2025-02-01T11:00:00Z"
}

GET /api/locations?cutId=:id

Fetch locations within cut boundary.

Authentication: Required

Query Parameters:

  • cutId (required): Cut ID for filtering
  • sortBy (optional): Field to sort by (default: "address")
  • order (optional): "asc" or "desc" (default: "asc")

Response:

{
  "data": [
    {
      "id": 1001,
      "address": "123 Main St",
      "latitude": 43.6532,
      "longitude": -79.3832,
      "postalCode": "M5H 2N2",
      "addresses": [
        {
          "id": 5001,
          "unitNumber": "101",
          "firstName": "John",
          "lastName": "Smith",
          "supportLevel": 4,
          "notes": "Lawn sign requested"
        },
        {
          "id": 5002,
          "unitNumber": "102",
          "firstName": "Jane",
          "lastName": "Doe",
          "supportLevel": 5,
          "notes": null
        }
      ]
    }
  ],
  "total": 150
}

Filtering Logic:

// Point-in-polygon filter
const locations = await prisma.location.findMany({
  where: {
    AND: [
      { latitude: { not: null } },
      { longitude: { not: null } }
    ]
  },
  include: {
    addresses: {
      orderBy: { unitNumber: 'asc' }
    }
  },
  orderBy: { address: 'asc' }
});

// Filter using point-in-polygon
const filtered = locations.filter(loc =>
  isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson)
);

POST /api/qr/generate

Generate QR code PNG from URL.

Authentication: None (public endpoint)

Request Body:

{
  "url": "https://example.com/campaign",
  "size": 200
}

Parameters:

  • url (required): Target URL for QR code
  • size (optional): QR code dimension in pixels (default: 200, max: 500)

Response:

{
  "png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
}

Error Responses:

  • 400: Invalid URL format
  • 400: Size must be between 50-500
  • 500: QR code generation failed

Rate Limiting: 100 requests per 15 minutes per IP

Configuration

Environment Variables

Variable Type Default Description
N/A Walk sheet settings stored in database

MapSettings Configuration

Access via: Admin → Settings → Map Settings

Setting Type Default Max Length Description
walkSheetTitle string "Walk Sheet" 100 Header title for walk sheets
walkSheetSubtitle string "" 200 Subtitle below title (ward, date, etc.)
walkSheetFooter string "" 500 Footer text (contact info, instructions)
qrCode1Url string null 2048 First QR code target URL
qrCode1Label string null 50 First QR code label
qrCode2Url string null 2048 Second QR code target URL
qrCode2Label string null 50 Second QR code label
qrCode3Url string null 2048 Third QR code target URL
qrCode3Label string null 50 Third QR code label

QR Code URL Examples:

  • Campaign page: https://example.com/campaigns/123
  • Volunteer portal: https://example.com/volunteer
  • Donation page: https://example.com/donate
  • Social media: https://facebook.com/campaignpage
  • Google Form: https://forms.google.com/...

QR Code Label Best Practices:

  • Keep short (2-4 words)
  • Action-oriented ("Donate Now", "Get Updates")
  • Mobile-friendly (scanned on phones)
  • Clear purpose ("Campaign Details", "Volunteer Info")

Print Configuration

CSS Variables:

@media print {
  --print-margin: 0.5in;
  --print-font-size: 10pt;
  --print-header-size: 16pt;
  --print-qr-size: 150px;
  --print-table-border: 1px solid #000;
}

Page Setup:

  • Size: A4 (210mm × 297mm) or Letter (8.5" × 11")
  • Orientation: Portrait
  • Margins: 0.5 inches (12.7mm)
  • Print background: Enabled (for borders)
  • Scale: 100% (no auto-fit)

Admin Workflow

Configure Walk Sheet Settings

Step 1: Navigate to Map Settings

  1. Log in as SUPER_ADMIN or MAP_ADMIN
  2. Click Settings in sidebar
  3. Click Map Settings submenu
  4. Scroll to "Walk Sheet Configuration" section

Step 2: Set Title and Subtitle

Walk Sheet Title: "Toronto Canvass Walk Sheet"
Walk Sheet Subtitle: "Ward 10 - November 2025 Campaign"

Step 3: Configure QR Codes

QR Code 1:
  URL: https://example.com/campaign/123
  Label: Campaign Page

QR Code 2:
  URL: https://example.com/volunteer
  Label: Volunteer Sign-Up

QR Code 3:
  URL: https://example.com/donate
  Label: Donate Now

Step 4: Set Footer Text

Walk Sheet Footer:
  Questions? Call HQ at 416-555-1234
  Emergency? Text volunteer coordinator at 416-555-5678
  Return completed sheets to campaign office by 8 PM

Step 5: Save Settings

  • Click Save button
  • Success notification appears
  • Settings applied to all future walk sheets

Generate Walk Sheet

Step 1: Navigate to Walk Sheet Page

  1. Click Map in sidebar
  2. Click Walk Sheet submenu
  3. Walk sheet generator page loads

Step 2: Select Cut

  1. Click Select Cut dropdown
  2. Choose cut from list (e.g., "Downtown Core")
  3. Loading indicator shows while fetching locations
  4. Location count displayed (e.g., "150 locations")

Step 3: Preview Walk Sheet

Walk sheet displays:

┌─────────────────────────────────────────────┐
│  Toronto Canvass Walk Sheet                 │
│  Ward 10 - November 2025 Campaign           │
│  Cut: Downtown Core                         │
│  Date: February 13, 2026                    │
└─────────────────────────────────────────────┘

┌───────────────────┬──────┬────────┬─────────┐
│ Address           │ Unit │ Notes  │ Visited │
├───────────────────┼──────┼────────┼─────────┤
│ 100 Adelaide St E │ 101  │ Lawn   │    □    │
│ 100 Adelaide St E │ 102  │        │    □    │
│ 102 Adelaide St E │      │        │    □    │
│ 105 Bay St        │ 1A   │ Strong │    □    │
└───────────────────┴──────┴────────┴─────────┘

[QR Code]        [QR Code]        [QR Code]
Campaign Page    Volunteer Info    Donate Now

Questions? Call HQ at 416-555-1234
Emergency? Text volunteer coordinator at 416-555-5678
Return completed sheets to campaign office by 8 PM

Step 4: Print Walk Sheet

  1. Click Print button (top-right corner)
  2. Browser print dialog opens
  3. Configure print settings:
    • Destination: Printer or "Save as PDF"
    • Pages: All
    • Layout: Portrait
    • Margins: Default
    • Background graphics: Enabled
  4. Click Print or Save

Step 5: Distribute to Volunteers

  • Print multiple copies for shift volunteers
  • Include shift assignment sheet
  • Provide pens for checkboxes and notes
  • Brief volunteers on walk sheet usage

Generate Cut Export Report

Step 1: Navigate to Cuts Page

  1. Click MapCuts in sidebar
  2. Cuts table loads with list of all cuts

Step 2: Open Cut Export

  1. Find cut row (e.g., "Downtown Core")
  2. Click Export button in Actions column
  3. New tab opens with export report

Step 3: Review Statistics

Export report shows:

┌─────────────────────────────────────────────┐
│  Cut Export Report                          │
│  Cut: Downtown Core                         │
│  Generated: February 13, 2026 10:30 AM      │
└─────────────────────────────────────────────┘

Statistics:
  Total Locations: 150
  Total Units: 287
  Residential: 280 (97.6%)
  Commercial: 7 (2.4%)
  Geocoded: 148 (98.7%)
  Missing Coordinates: 2 (1.3%)

┌─────────────────┬──────┬───────┬──────────┐
│ Address         │ Lat  │ Lng   │ Units    │
├─────────────────┼──────┼───────┼──────────┤
│ 100 Adelaide E  │ 43.6 │ -79.3 │ 2        │
│ 102 Adelaide E  │ 43.6 │ -79.3 │ 1        │
└─────────────────┴──────┴───────┴──────────┘

[Export CSV Button]  [Print Button]

Step 4: Export to CSV

  1. Click Export CSV button
  2. File downloads: cut-42-downtown-core-2026-02-13.csv
  3. Open in spreadsheet for further analysis

CSV Format:

Address,Latitude,Longitude,Postal Code,Units,Residential
"100 Adelaide St E",43.6532,-79.3832,"M5H 2N2",2,true
"102 Adelaide St E",43.6540,-79.3825,"M5H 2N3",1,true

Print Layout

Page Structure

┌─────────────────────────────────────────────┐
│ [HEADER SECTION]                            │
│   - Walk Sheet Title                        │
│   - Subtitle                                │
│   - Cut Name                                │
│   - Generated Date                          │
├─────────────────────────────────────────────┤
│ [ADDRESS TABLE]                             │
│   - Sortable by street name                │
│   - Multi-unit grouped                      │
│   - Support level indicators               │
│   - Notes column                            │
│   - Visited checkbox                        │
├─────────────────────────────────────────────┤
│ [QR CODE SECTION]                           │
│   - Up to 3 QR codes                        │
│   - Labels below each code                  │
│   - Horizontal layout                       │
├─────────────────────────────────────────────┤
│ [FOOTER SECTION]                            │
│   - Custom footer text                      │
│   - Contact information                     │
│   - Instructions                            │
└─────────────────────────────────────────────┘

CSS Print Rules

Component: WalkSheetPage.tsx

@media print {
  /* Hide non-printable elements */
  .no-print,
  .ant-layout-header,
  .ant-layout-sider,
  button,
  .ant-select,
  .ant-form,
  nav {
    display: none !important;
  }

  /* Page setup */
  @page {
    size: A4 portrait;
    margin: 0.5in;
  }

  body {
    font-size: 10pt;
    line-height: 1.4;
    color: #000;
    background: #fff;
  }

  /* Header styling */
  .walk-sheet-header {
    text-align: center;
    margin-bottom: 20px;
    border-bottom: 2px solid #000;
    padding-bottom: 10px;
  }

  .walk-sheet-title {
    font-size: 16pt;
    font-weight: bold;
    margin-bottom: 5px;
  }

  .walk-sheet-subtitle {
    font-size: 12pt;
    color: #333;
  }

  /* Table styling */
  table {
    width: 100%;
    border-collapse: collapse;
    page-break-inside: avoid;
    margin-bottom: 20px;
  }

  th, td {
    border: 1px solid #000;
    padding: 6px;
    text-align: left;
  }

  th {
    background-color: #f0f0f0;
    font-weight: bold;
    font-size: 9pt;
  }

  td {
    font-size: 9pt;
  }

  /* Prevent row breaks */
  tr {
    page-break-inside: avoid;
  }

  /* QR code section */
  .qr-code-section {
    display: flex;
    justify-content: space-around;
    margin: 20px 0;
    page-break-inside: avoid;
  }

  .qr-code-item {
    text-align: center;
    width: 150px;
  }

  .qr-code-item img {
    width: 150px;
    height: 150px;
    margin-bottom: 5px;
  }

  .qr-code-label {
    font-size: 9pt;
    font-weight: bold;
  }

  /* Footer styling */
  .walk-sheet-footer {
    margin-top: 20px;
    padding-top: 10px;
    border-top: 1px solid #000;
    font-size: 9pt;
    white-space: pre-wrap;
  }

  /* Checkbox styling */
  .visited-checkbox {
    width: 15px;
    height: 15px;
    border: 1px solid #000;
    display: inline-block;
  }

  /* Support level indicators */
  .support-level-1 { color: #e74c3c; } /* Strong Opposition */
  .support-level-2 { color: #f39c12; } /* Lean Opposition */
  .support-level-3 { color: #95a5a6; } /* Undecided */
  .support-level-4 { color: #3498db; } /* Lean Support */
  .support-level-5 { color: #27ae60; } /* Strong Support */
}

Address Table Layout

Column Structure:

Column Width Content Sort
Address 40% Street address Alphabetical
Unit 10% Unit/apartment number Alphanumeric
Name 20% First + Last name Alphabetical
Support 10% Support level (1-5) Color-coded
Notes 15% Canvasser notes N/A
Visited 5% Checkbox N/A

Multi-Unit Grouping:

┌───────────────────┬──────┬────────────┬─────────┬────────┬─────────┐
│ Address           │ Unit │ Name       │ Support │ Notes  │ Visited │
├───────────────────┼──────┼────────────┼─────────┼────────┼─────────┤
│ 100 Adelaide St E │ 101  │ John Smith │    4    │ Lawn   │    □    │
│ 100 Adelaide St E │ 102  │ Jane Doe   │    5    │        │    □    │
│ 100 Adelaide St E │ 103  │            │         │        │    □    │
├───────────────────┼──────┼────────────┼─────────┼────────┼─────────┤
│ 102 Adelaide St E │      │            │         │        │    □    │
└───────────────────┴──────┴────────────┴─────────┴────────┴─────────┘

Support Level Colors:

  • 1 (Strong Opposition): Red (#e74c3c)
  • 2 (Lean Opposition): Orange (#f39c12)
  • 3 (Undecided): Gray (#95a5a6)
  • 4 (Lean Support): Blue (#3498db)
  • 5 (Strong Support): Green (#27ae60)

QR Code Layout

Horizontal Layout:

    [QR 150×150]         [QR 150×150]         [QR 150×150]
    Campaign Page        Volunteer Info        Donate Now

QR Code Generation:

  • Size: 150×150 pixels
  • Error correction: Medium (M)
  • Format: PNG with transparent background
  • Encoding: UTF-8
  • Margin: 4 modules

Spacing:

  • Between codes: 30px
  • Above section: 20px
  • Below section: 20px
  • Label margin: 5px

Cut Export Page

Export Report Structure

Component: CutExportPage.tsx

Route: /app/map/cuts/:id/export

Layout:

┌─────────────────────────────────────────────┐
│  Cut Export Report                          │
│  [Cut Name]                                 │
│  Generated: [Date Time]                     │
├─────────────────────────────────────────────┤
│  STATISTICS PANEL                           │
│  ┌──────────────┬──────────────┐            │
│  │ Total Locs   │ Geocoded     │            │
│  │ 150          │ 148 (98.7%)  │            │
│  ├──────────────┼──────────────┤            │
│  │ Total Units  │ Residential  │            │
│  │ 287          │ 280 (97.6%)  │            │
│  └──────────────┴──────────────┘            │
├─────────────────────────────────────────────┤
│  LOCATION TABLE                             │
│  [Sortable, filterable table]              │
├─────────────────────────────────────────────┤
│  ACTIONS                                    │
│  [Export CSV] [Print]                       │
└─────────────────────────────────────────────┘

Statistics Panel

Metrics Displayed:

  1. Total Locations: Count of locations within cut
  2. Total Units: Sum of addresses across all locations
  3. Geocoded Locations: Locations with lat/lng (% of total)
  4. Missing Coordinates: Locations without lat/lng
  5. Residential Units: Units with buildingUse = 1
  6. Commercial Units: Units with buildingUse != 1
  7. Support Level Breakdown: Count by level (1-5)
  8. Cut Area: Approximate area in square kilometers

Statistics Calculation:

interface CutStatistics {
  totalLocations: number;
  totalUnits: number;
  geocodedCount: number;
  geocodedPercent: number;
  missingCoordinates: number;
  residentialCount: number;
  residentialPercent: number;
  commercialCount: number;
  supportLevelBreakdown: Record<number, number>;
  cutAreaKm2: number;
}

const calculateStats = (locations: Location[]): CutStatistics => {
  const totalLocations = locations.length;
  const totalUnits = locations.reduce((sum, loc) =>
    sum + loc.addresses.length, 0);
  const geocodedCount = locations.filter(loc =>
    loc.latitude && loc.longitude).length;
  const residentialCount = locations.filter(loc =>
    loc.buildingUse === 1).length;

  const supportLevelBreakdown = {};
  locations.forEach(loc => {
    loc.addresses.forEach(addr => {
      if (addr.supportLevel) {
        supportLevelBreakdown[addr.supportLevel] =
          (supportLevelBreakdown[addr.supportLevel] || 0) + 1;
      }
    });
  });

  return {
    totalLocations,
    totalUnits,
    geocodedCount,
    geocodedPercent: (geocodedCount / totalLocations) * 100,
    missingCoordinates: totalLocations - geocodedCount,
    residentialCount,
    residentialPercent: (residentialCount / totalLocations) * 100,
    commercialCount: totalLocations - residentialCount,
    supportLevelBreakdown,
    cutAreaKm2: calculatePolygonArea(cut.geojson)
  };
};

Location Table

Columns:

Column Data Format
Address location.address String
Latitude location.latitude 6 decimals
Longitude location.longitude 6 decimals
Postal Code location.postalCode Uppercase
Units addresses.length Integer
Residential buildingUse === 1 Boolean
Support Avg avg(addresses.supportLevel) 1 decimal

Table Features:

  • Sortable by all columns
  • Filterable by postal code prefix
  • Pagination (50 per page)
  • Export selected rows to CSV
  • Highlight locations with missing coordinates
  • Color-code by average support level

CSV Export

Export Button Handler:

const exportToCSV = () => {
  const headers = [
    'Address',
    'Latitude',
    'Longitude',
    'Postal Code',
    'Units',
    'Residential',
    'Support Average',
    'Federal District'
  ];

  const rows = locations.map(loc => [
    loc.address,
    loc.latitude?.toFixed(6) || '',
    loc.longitude?.toFixed(6) || '',
    loc.postalCode || '',
    loc.addresses.length,
    loc.buildingUse === 1 ? 'Yes' : 'No',
    calculateAverageSupportLevel(loc.addresses).toFixed(1),
    loc.federalDistrict || ''
  ]);

  const csv = [headers, ...rows]
    .map(row => row.map(cell => `"${cell}"`).join(','))
    .join('\n');

  const blob = new Blob([csv], { type: 'text/csv' });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = `cut-${cutId}-${cutName}-${new Date().toISOString().split('T')[0]}.csv`;
  link.click();
  URL.revokeObjectURL(url);
};

CSV Output Example:

"Address","Latitude","Longitude","Postal Code","Units","Residential","Support Average","Federal District"
"100 Adelaide St E","43.653200","-79.383200","M5H 2N2","2","Yes","4.5","Toronto Centre"
"102 Adelaide St E","43.654000","-79.382500","M5H 2N3","1","Yes","3.0","Toronto Centre"
"105 Bay St","43.650000","-79.380000","M5J 2R8","12","Yes","4.2","Toronto Centre"

Code Examples

WalkSheetPage.tsx - Component Structure

import React, { useEffect, useState } from 'react';
import { Select, Button, Table, Space, Spin, Typography, Row, Col } from 'antd';
import { PrinterOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { Cut, Location, MapSettings } from '@/types/api';

const { Title, Text } = Typography;

const WalkSheetPage: React.FC = () => {
  const [cuts, setCuts] = useState<Cut[]>([]);
  const [selectedCutId, setSelectedCutId] = useState<number | null>(null);
  const [locations, setLocations] = useState<Location[]>([]);
  const [settings, setSettings] = useState<MapSettings | null>(null);
  const [qrCodes, setQrCodes] = useState<Record<number, string>>({});
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    fetchCuts();
    fetchSettings();
  }, []);

  useEffect(() => {
    if (selectedCutId) {
      fetchLocations(selectedCutId);
    }
  }, [selectedCutId]);

  useEffect(() => {
    if (settings) {
      generateQRCodes();
    }
  }, [settings]);

  const fetchCuts = async () => {
    const { data } = await api.get<Cut[]>('/cuts');
    setCuts(data);
  };

  const fetchSettings = async () => {
    const { data } = await api.get<MapSettings>('/map-settings');
    setSettings(data);
  };

  const fetchLocations = async (cutId: number) => {
    setLoading(true);
    try {
      const { data } = await api.get<{ data: Location[] }>(
        `/locations?cutId=${cutId}&sortBy=address&order=asc`
      );
      setLocations(data.data);
    } finally {
      setLoading(false);
    }
  };

  const generateQRCodes = async () => {
    if (!settings) return;

    const codes: Record<number, string> = {};
    const qrUrls = [
      { url: settings.qrCode1Url, index: 1 },
      { url: settings.qrCode2Url, index: 2 },
      { url: settings.qrCode3Url, index: 3 }
    ].filter(item => item.url);

    for (const { url, index } of qrUrls) {
      try {
        const { data } = await api.post('/qr/generate', { url, size: 150 });
        codes[index] = data.png;
      } catch (error) {
        console.error(`Failed to generate QR code ${index}:`, error);
      }
    }

    setQrCodes(codes);
  };

  const handlePrint = () => {
    window.print();
  };

  const columns = [
    {
      title: 'Address',
      dataIndex: 'address',
      key: 'address',
      width: '40%'
    },
    {
      title: 'Unit',
      key: 'unit',
      width: '10%',
      render: (_: any, record: Location) => (
        <Space direction="vertical" size={0}>
          {record.addresses.map(addr => (
            <Text key={addr.id}>{addr.unitNumber || '-'}</Text>
          ))}
        </Space>
      )
    },
    {
      title: 'Name',
      key: 'name',
      width: '20%',
      render: (_: any, record: Location) => (
        <Space direction="vertical" size={0}>
          {record.addresses.map(addr => (
            <Text key={addr.id}>
              {addr.firstName && addr.lastName
                ? `${addr.firstName} ${addr.lastName}`
                : '-'}
            </Text>
          ))}
        </Space>
      )
    },
    {
      title: 'Support',
      key: 'support',
      width: '10%',
      render: (_: any, record: Location) => (
        <Space direction="vertical" size={0}>
          {record.addresses.map(addr => (
            <Text
              key={addr.id}
              className={addr.supportLevel ? `support-level-${addr.supportLevel}` : ''}
            >
              {addr.supportLevel || '-'}
            </Text>
          ))}
        </Space>
      )
    },
    {
      title: 'Notes',
      key: 'notes',
      width: '15%',
      render: (_: any, record: Location) => (
        <Space direction="vertical" size={0}>
          {record.addresses.map(addr => (
            <Text key={addr.id} ellipsis={{ tooltip: addr.notes }}>
              {addr.notes || '-'}
            </Text>
          ))}
        </Space>
      )
    },
    {
      title: 'Visited',
      key: 'visited',
      width: '5%',
      render: (_: any, record: Location) => (
        <Space direction="vertical" size={0}>
          {record.addresses.map(addr => (
            <div key={addr.id} className="visited-checkbox" />
          ))}
        </Space>
      )
    }
  ];

  const selectedCut = cuts.find(c => c.id === selectedCutId);

  return (
    <div className="walk-sheet-page">
      {/* Controls - hidden when printing */}
      <div className="no-print" style={{ marginBottom: 24 }}>
        <Space>
          <Select
            style={{ width: 300 }}
            placeholder="Select a cut"
            value={selectedCutId}
            onChange={setSelectedCutId}
            options={cuts.map(cut => ({
              label: cut.name,
              value: cut.id
            }))}
          />
          <Button
            type="primary"
            icon={<PrinterOutlined />}
            onClick={handlePrint}
            disabled={!selectedCutId || loading}
          >
            Print
          </Button>
        </Space>
      </div>

      {/* Walk Sheet Content - printed */}
      {selectedCutId && settings && (
        <>
          {/* Header */}
          <div className="walk-sheet-header">
            <Title level={2} className="walk-sheet-title">
              {settings.walkSheetTitle}
            </Title>
            {settings.walkSheetSubtitle && (
              <Text className="walk-sheet-subtitle">
                {settings.walkSheetSubtitle}
              </Text>
            )}
            <div style={{ marginTop: 8 }}>
              <Text strong>Cut: </Text>
              <Text>{selectedCut?.name}</Text>
              <br />
              <Text strong>Date: </Text>
              <Text>{new Date().toLocaleDateString()}</Text>
            </div>
          </div>

          {/* Address Table */}
          {loading ? (
            <div style={{ textAlign: 'center', padding: 40 }}>
              <Spin size="large" />
            </div>
          ) : (
            <Table
              dataSource={locations}
              columns={columns}
              pagination={false}
              rowKey="id"
              bordered
            />
          )}

          {/* QR Codes */}
          {Object.keys(qrCodes).length > 0 && (
            <Row gutter={16} className="qr-code-section">
              {[1, 2, 3].map(index => {
                const qrUrl = settings[`qrCode${index}Url` as keyof MapSettings];
                const qrLabel = settings[`qrCode${index}Label` as keyof MapSettings];
                if (!qrUrl || !qrCodes[index]) return null;

                return (
                  <Col key={index} span={8} className="qr-code-item">
                    <img src={qrCodes[index]} alt={`QR Code ${index}`} />
                    <div className="qr-code-label">{qrLabel}</div>
                  </Col>
                );
              })}
            </Row>
          )}

          {/* Footer */}
          {settings.walkSheetFooter && (
            <div className="walk-sheet-footer">
              {settings.walkSheetFooter}
            </div>
          )}
        </>
      )}

      {/* Print Styles */}
      <style>{`
        @media print {
          .no-print {
            display: none !important;
          }

          @page {
            size: A4 portrait;
            margin: 0.5in;
          }

          body {
            font-size: 10pt;
            line-height: 1.4;
          }

          .walk-sheet-header {
            text-align: center;
            margin-bottom: 20px;
            border-bottom: 2px solid #000;
            padding-bottom: 10px;
          }

          .walk-sheet-title {
            font-size: 16pt !important;
            margin-bottom: 5px !important;
          }

          .walk-sheet-subtitle {
            font-size: 12pt;
          }

          table {
            page-break-inside: avoid;
          }

          th, td {
            font-size: 9pt !important;
            padding: 6px !important;
          }

          .visited-checkbox {
            width: 15px;
            height: 15px;
            border: 1px solid #000;
            display: inline-block;
          }

          .support-level-1 { color: #e74c3c; }
          .support-level-2 { color: #f39c12; }
          .support-level-3 { color: #95a5a6; }
          .support-level-4 { color: #3498db; }
          .support-level-5 { color: #27ae60; }

          .qr-code-section {
            display: flex;
            justify-content: space-around;
            margin: 20px 0;
            page-break-inside: avoid;
          }

          .qr-code-item {
            text-align: center;
          }

          .qr-code-item img {
            width: 150px;
            height: 150px;
          }

          .qr-code-label {
            font-size: 9pt;
            font-weight: bold;
            margin-top: 5px;
          }

          .walk-sheet-footer {
            margin-top: 20px;
            padding-top: 10px;
            border-top: 1px solid #000;
            font-size: 9pt;
            white-space: pre-wrap;
          }
        }
      `}</style>
    </div>
  );
};

export default WalkSheetPage;

QR Code API - qr.routes.ts

import { Router } from 'express';
import QRCode from 'qrcode';
import { z } from 'zod';
import { validate } from '@/middleware/validate';
import rateLimit from 'express-rate-limit';

const router = Router();

// Rate limiter: 100 requests per 15 minutes
const qrLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  message: 'Too many QR code requests, please try again later'
});

const generateQRSchema = z.object({
  body: z.object({
    url: z.string().url('Must be a valid URL'),
    size: z.number().int().min(50).max(500).optional().default(200)
  })
});

/**
 * POST /api/qr/generate
 * Generate QR code PNG from URL
 * Public endpoint (no authentication)
 */
router.post(
  '/generate',
  qrLimiter,
  validate(generateQRSchema),
  async (req, res, next) => {
    try {
      const { url, size } = req.body;

      // Generate QR code as data URL
      const png = await QRCode.toDataURL(url, {
        width: size,
        margin: 4,
        errorCorrectionLevel: 'M',
        type: 'image/png'
      });

      res.json({ png });
    } catch (error) {
      next(error);
    }
  }
);

export default router;

MapSettingsPage.tsx - QR Code Configuration

import React, { useEffect } from 'react';
import { Form, Input, Button, message, Divider, Space, Typography } from 'antd';
import { api } from '@/lib/api';
import type { MapSettings } from '@/types/api';

const { Title, Text } = Typography;

const MapSettingsPage: React.FC = () => {
  const [form] = Form.useForm();
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    fetchSettings();
  }, []);

  const fetchSettings = async () => {
    setLoading(true);
    try {
      const { data } = await api.get<MapSettings>('/map-settings');
      form.setFieldsValue(data);
    } catch (error) {
      message.error('Failed to load map settings');
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = async (values: Partial<MapSettings>) => {
    setLoading(true);
    try {
      await api.put('/map-settings', values);
      message.success('Settings saved successfully');
    } catch (error) {
      message.error('Failed to save settings');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <Title level={2}>Map Settings</Title>

      <Form
        form={form}
        layout="vertical"
        onFinish={handleSubmit}
        disabled={loading}
      >
        <Divider orientation="left">Walk Sheet Configuration</Divider>

        <Form.Item
          label="Walk Sheet Title"
          name="walkSheetTitle"
          rules={[
            { required: true, message: 'Title is required' },
            { max: 100, message: 'Maximum 100 characters' }
          ]}
        >
          <Input placeholder="Walk Sheet" />
        </Form.Item>

        <Form.Item
          label="Walk Sheet Subtitle"
          name="walkSheetSubtitle"
          rules={[{ max: 200, message: 'Maximum 200 characters' }]}
        >
          <Input placeholder="Ward 10 - November 2025" />
        </Form.Item>

        <Form.Item
          label="Walk Sheet Footer"
          name="walkSheetFooter"
          rules={[{ max: 500, message: 'Maximum 500 characters' }]}
        >
          <Input.TextArea
            rows={4}
            placeholder="Contact information, instructions, etc."
          />
        </Form.Item>

        <Divider orientation="left">QR Code 1</Divider>

        <Form.Item
          label="QR Code 1 URL"
          name="qrCode1Url"
          rules={[{ type: 'url', message: 'Must be a valid URL' }]}
        >
          <Input placeholder="https://example.com/campaign" />
        </Form.Item>

        <Form.Item
          label="QR Code 1 Label"
          name="qrCode1Label"
          rules={[{ max: 50, message: 'Maximum 50 characters' }]}
        >
          <Input placeholder="Campaign Page" />
        </Form.Item>

        <Divider orientation="left">QR Code 2</Divider>

        <Form.Item
          label="QR Code 2 URL"
          name="qrCode2Url"
          rules={[{ type: 'url', message: 'Must be a valid URL' }]}
        >
          <Input placeholder="https://example.com/volunteer" />
        </Form.Item>

        <Form.Item
          label="QR Code 2 Label"
          name="qrCode2Label"
          rules={[{ max: 50, message: 'Maximum 50 characters' }]}
        >
          <Input placeholder="Volunteer Info" />
        </Form.Item>

        <Divider orientation="left">QR Code 3</Divider>

        <Form.Item
          label="QR Code 3 URL"
          name="qrCode3Url"
          rules={[{ type: 'url', message: 'Must be a valid URL' }]}
        >
          <Input placeholder="https://example.com/donate" />
        </Form.Item>

        <Form.Item
          label="QR Code 3 Label"
          name="qrCode3Label"
          rules={[{ max: 50, message: 'Maximum 50 characters' }]}
        >
          <Input placeholder="Donate Now" />
        </Form.Item>

        <Form.Item>
          <Space>
            <Button type="primary" htmlType="submit" loading={loading}>
              Save Settings
            </Button>
            <Button onClick={fetchSettings}>Reset</Button>
          </Space>
        </Form.Item>
      </Form>
    </div>
  );
};

export default MapSettingsPage;

CutExportPage.tsx - Statistics and CSV Export

import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button, Table, Card, Row, Col, Statistic, Space, message } from 'antd';
import { PrinterOutlined, DownloadOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { Cut, Location } from '@/types/api';

const CutExportPage: React.FC = () => {
  const { id } = useParams<{ id: string }>();
  const cutId = parseInt(id);

  const [cut, setCut] = useState<Cut | null>(null);
  const [locations, setLocations] = useState<Location[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    fetchData();
  }, [cutId]);

  const fetchData = async () => {
    setLoading(true);
    try {
      const [cutRes, locsRes] = await Promise.all([
        api.get<Cut>(`/cuts/${cutId}`),
        api.get<{ data: Location[] }>(`/locations?cutId=${cutId}`)
      ]);
      setCut(cutRes.data);
      setLocations(locsRes.data.data);
    } catch (error) {
      message.error('Failed to load cut data');
    } finally {
      setLoading(false);
    }
  };

  const calculateStats = () => {
    const totalLocations = locations.length;
    const totalUnits = locations.reduce((sum, loc) => sum + loc.addresses.length, 0);
    const geocoded = locations.filter(loc => loc.latitude && loc.longitude).length;
    const residential = locations.filter(loc => loc.buildingUse === 1).length;

    return {
      totalLocations,
      totalUnits,
      geocoded,
      geocodedPercent: totalLocations > 0 ? (geocoded / totalLocations) * 100 : 0,
      residential,
      residentialPercent: totalLocations > 0 ? (residential / totalLocations) * 100 : 0
    };
  };

  const exportToCSV = () => {
    const headers = [
      'Address',
      'Latitude',
      'Longitude',
      'Postal Code',
      'Units',
      'Residential'
    ];

    const rows = locations.map(loc => [
      loc.address,
      loc.latitude?.toFixed(6) || '',
      loc.longitude?.toFixed(6) || '',
      loc.postalCode || '',
      loc.addresses.length,
      loc.buildingUse === 1 ? 'Yes' : 'No'
    ]);

    const csv = [headers, ...rows]
      .map(row => row.map(cell => `"${cell}"`).join(','))
      .join('\n');

    const blob = new Blob([csv], { type: 'text/csv' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = `cut-${cutId}-${cut?.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`;
    link.click();
    URL.revokeObjectURL(url);
  };

  const handlePrint = () => {
    window.print();
  };

  const stats = calculateStats();

  const columns = [
    { title: 'Address', dataIndex: 'address', key: 'address' },
    {
      title: 'Latitude',
      dataIndex: 'latitude',
      key: 'latitude',
      render: (val: number) => val?.toFixed(6) || 'N/A'
    },
    {
      title: 'Longitude',
      dataIndex: 'longitude',
      key: 'longitude',
      render: (val: number) => val?.toFixed(6) || 'N/A'
    },
    { title: 'Postal Code', dataIndex: 'postalCode', key: 'postalCode' },
    {
      title: 'Units',
      key: 'units',
      render: (_: any, record: Location) => record.addresses.length
    },
    {
      title: 'Residential',
      dataIndex: 'buildingUse',
      key: 'residential',
      render: (val: number) => val === 1 ? 'Yes' : 'No'
    }
  ];

  return (
    <div className="cut-export-page">
      <div className="no-print" style={{ marginBottom: 24 }}>
        <Space>
          <Button icon={<PrinterOutlined />} onClick={handlePrint}>
            Print
          </Button>
          <Button icon={<DownloadOutlined />} onClick={exportToCSV}>
            Export CSV
          </Button>
        </Space>
      </div>

      <div className="cut-export-header">
        <h1>Cut Export Report</h1>
        <h2>{cut?.name}</h2>
        <p>Generated: {new Date().toLocaleString()}</p>
      </div>

      <Card title="Statistics" style={{ marginBottom: 24 }}>
        <Row gutter={16}>
          <Col span={6}>
            <Statistic title="Total Locations" value={stats.totalLocations} />
          </Col>
          <Col span={6}>
            <Statistic title="Total Units" value={stats.totalUnits} />
          </Col>
          <Col span={6}>
            <Statistic
              title="Geocoded"
              value={stats.geocoded}
              suffix={`(${stats.geocodedPercent.toFixed(1)}%)`}
            />
          </Col>
          <Col span={6}>
            <Statistic
              title="Residential"
              value={stats.residential}
              suffix={`(${stats.residentialPercent.toFixed(1)}%)`}
            />
          </Col>
        </Row>
      </Card>

      <Table
        dataSource={locations}
        columns={columns}
        rowKey="id"
        loading={loading}
        pagination={{ pageSize: 50 }}
      />

      <style>{`
        @media print {
          .no-print { display: none !important; }
          @page { size: A4 landscape; margin: 0.5in; }
          body { font-size: 10pt; }
        }
      `}</style>
    </div>
  );
};

export default CutExportPage;

Troubleshooting

Problem: QR codes not generating

Symptoms:

  • Empty QR code section on walk sheet
  • Console errors about /api/qr/generate
  • Network 404 or 500 errors

Solutions:

  1. Verify endpoint accessibility:
curl -X POST http://localhost:4000/api/qr/generate \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com","size":200}'
  1. Check qrcode package installed:
cd api
npm list qrcode
# If not installed:
npm install qrcode
npm install --save-dev @types/qrcode
  1. Verify route registration in server.ts:
import qrRoutes from './modules/qr/qr.routes';
app.use('/api/qr', qrRoutes);
  1. Check URL validation:
// URL must start with http:// or https://
const validUrls = [
  'https://example.com',  // ✓ Valid
  'http://example.com',   // ✓ Valid
  'example.com',          // ✗ Invalid (missing protocol)
  'ftp://example.com'     // ✗ Invalid (wrong protocol)
];
  1. Test with simple URL:
// Test with minimal payload
const testQR = async () => {
  const { data } = await api.post('/qr/generate', {
    url: 'https://google.com'
    // size omitted (uses default 200)
  });
  console.log('QR generated:', data.png.substring(0, 50));
};

Problem: Print layout broken

Symptoms:

  • Elements overlap when printing
  • Missing borders or backgrounds
  • Incorrect page breaks
  • Cut-off content

Solutions:

  1. Enable background graphics in browser:

    • Chrome: Print → More settings → Background graphics (checked)
    • Firefox: Print → Options → Print backgrounds (checked)
    • Safari: Print → Show Details → Print backgrounds (checked)
  2. Test print preview first:

// Add print preview button for debugging
const handlePrintPreview = () => {
  const printWindow = window.open('', '_blank');
  printWindow?.document.write(document.documentElement.outerHTML);
  printWindow?.print();
};
  1. Check @page margins:
@media print {
  @page {
    size: A4 portrait;
    margin: 0.5in; /* Adjust if content cut off */
  }
}
  1. Prevent table row breaks:
@media print {
  tr {
    page-break-inside: avoid;
    page-break-after: auto;
  }

  thead {
    display: table-header-group; /* Repeat on each page */
  }
}
  1. Test in different browsers:

    • Chrome/Edge: Best print CSS support
    • Firefox: Good, but some layout differences
    • Safari: May require webkit prefixes
  2. Adjust font sizes if content overflows:

@media print {
  body { font-size: 9pt; } /* Reduce from 10pt */
  th, td { font-size: 8pt; } /* Reduce from 9pt */
}

Problem: Walk sheet showing wrong cut

Symptoms:

  • Selected cut shows different locations
  • Location count doesn't match cut
  • Locations outside cut boundary visible

Solutions:

  1. Verify cutId in API request:
console.log('Fetching locations for cut:', selectedCutId);
const { data } = await api.get(`/locations?cutId=${selectedCutId}`);
console.log('Received locations:', data.data.length);
  1. Check point-in-polygon filter:
// In locations.service.ts
const locations = await prisma.location.findMany({
  where: {
    AND: [
      { latitude: { not: null } },
      { longitude: { not: null } }
    ]
  }
});

// Filter by cut boundary
const cut = await prisma.cut.findUnique({ where: { id: cutId } });
const filtered = locations.filter(loc =>
  isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson)
);

console.log('Total locations:', locations.length);
console.log('Within cut:', filtered.length);
  1. Test with simple rectangular cut:
{
  "type": "Polygon",
  "coordinates": [
    [
      [-79.40, 43.64],
      [-79.36, 43.64],
      [-79.36, 43.66],
      [-79.40, 43.66],
      [-79.40, 43.64]
    ]
  ]
}
  1. Verify GeoJSON coordinate order:
// Correct: [longitude, latitude]
const point = [loc.longitude, loc.latitude]; // ✓

// Incorrect: [latitude, longitude]
const point = [loc.latitude, loc.longitude]; // ✗
  1. Check cut geojson validity:
    • First and last coordinates must be identical (closed polygon)
    • Coordinates must be [lng, lat] order
    • Use http://geojson.io to visualize

Problem: Large cuts slow to load

Symptoms:

  • Walk sheet takes > 10 seconds to load
  • Browser freezes during render
  • Print preview crashes

Solutions:

  1. Implement pagination:
const LOCATIONS_PER_PAGE = 50;

const [currentPage, setCurrentPage] = useState(1);
const paginatedLocations = locations.slice(
  (currentPage - 1) * LOCATIONS_PER_PAGE,
  currentPage * LOCATIONS_PER_PAGE
);
  1. Add location count warning:
{locations.length > 200 && (
  <Alert
    message="Large Cut"
    description={`This cut has ${locations.length} locations. Consider splitting into smaller sheets.`}
    type="warning"
    showIcon
  />
)}
  1. Use virtual scrolling for preview:
import { List } from 'react-virtualized';

// Render only visible rows during preview
<List
  height={600}
  rowCount={locations.length}
  rowHeight={40}
  rowRenderer={({ index, style }) => (
    <div style={style}>{renderLocationRow(locations[index])}</div>
  )}
/>
  1. Optimize QR code generation:
// Generate QR codes only when print button clicked
const [qrCodesGenerated, setQrCodesGenerated] = useState(false);

const handlePrint = async () => {
  if (!qrCodesGenerated) {
    await generateQRCodes();
    setQrCodesGenerated(true);
  }
  window.print();
};
  1. Split large cuts into multiple sheets:
// Group by postal code prefix
const groupedByPostal = locations.reduce((acc, loc) => {
  const prefix = loc.postalCode?.substring(0, 3) || 'Unknown';
  if (!acc[prefix]) acc[prefix] = [];
  acc[prefix].push(loc);
  return acc;
}, {} as Record<string, Location[]>);

// Generate separate sheet per group
Object.entries(groupedByPostal).forEach(([prefix, locs]) => {
  console.log(`${prefix}: ${locs.length} locations`);
});

Performance Considerations

Client-Side Rendering

Walk Sheet Page Load:

  • Initial load: ~500ms (fetch cuts + settings)
  • Cut selection: ~1-2 seconds (fetch locations + generate QR codes)
  • Large cuts (500+ locations): ~3-5 seconds
  • QR code generation: ~100ms per code (parallel)

Optimization Strategies:

  1. Lazy load QR codes:
// Only generate when visible
const [qrCodesVisible, setQrCodesVisible] = useState(false);

useEffect(() => {
  const observer = new IntersectionObserver(entries => {
    if (entries[0].isIntersecting && !qrCodesVisible) {
      generateQRCodes();
      setQrCodesVisible(true);
    }
  });
  observer.observe(qrSectionRef.current);
  return () => observer.disconnect();
}, []);
  1. Cache QR codes in localStorage:
const getCachedQR = (url: string): string | null => {
  const cached = localStorage.getItem(`qr:${url}`);
  if (cached) {
    const { png, timestamp } = JSON.parse(cached);
    // Cache valid for 24 hours
    if (Date.now() - timestamp < 24 * 60 * 60 * 1000) {
      return png;
    }
  }
  return null;
};

const cacheQR = (url: string, png: string) => {
  localStorage.setItem(`qr:${url}`, JSON.stringify({
    png,
    timestamp: Date.now()
  }));
};
  1. Debounce cut selection:
import { debounce } from 'lodash';

const debouncedFetchLocations = debounce((cutId: number) => {
  fetchLocations(cutId);
}, 300);

<Select onChange={debouncedFetchLocations} />

Server-Side Performance

API Response Times:

  • GET /api/map-settings: ~50ms (singleton query)
  • GET /api/cuts/🆔 ~100ms (single record + geojson)
  • GET /api/locations?cutId=X: ~500ms-2s (depends on cut size)
  • POST /api/qr/generate: ~50ms (QRCode.toDataURL is fast)

Database Optimization:

-- Index for cut location queries
CREATE INDEX idx_locations_coords ON "Location"(latitude, longitude);

-- Index for address sorting
CREATE INDEX idx_locations_address ON "Location"(address);

-- Composite index for geocoded locations
CREATE INDEX idx_locations_geocoded ON "Location"(latitude, longitude)
  WHERE latitude IS NOT NULL AND longitude IS NOT NULL;

Query Optimization:

// Use select to limit fields
const locations = await prisma.location.findMany({
  where: { /* filters */ },
  select: {
    id: true,
    address: true,
    latitude: true,
    longitude: true,
    postalCode: true,
    addresses: {
      select: {
        id: true,
        unitNumber: true,
        firstName: true,
        lastName: true,
        supportLevel: true,
        notes: true
      },
      orderBy: { unitNumber: 'asc' }
    }
  }
});

Print Performance

Print Dialog Load Time:

  • Small walk sheets (<50 locations): Instant
  • Medium (50-200 locations): 1-2 seconds
  • Large (200-500 locations): 3-5 seconds
  • Very large (500+ locations): Consider pagination

Browser Print Limits:

  • Chrome: ~1000 table rows before slowdown
  • Firefox: ~800 table rows
  • Safari: ~600 table rows

Optimization:

  • Use page-break-inside: avoid sparingly
  • Minimize complex CSS in print rules
  • Avoid large images (QR codes already optimized at 150px)
  • Split very large cuts into multiple PDFs

Backend Documentation

  • QR Code Generation: api/src/modules/qr/qr.routes.ts

    • QRCode.toDataURL() wrapper
    • Rate limiting (100/15min)
    • Size validation (50-500px)
  • Map Settings: api/src/modules/map/settings/

    • MapSettings singleton CRUD
    • Walk sheet configuration
    • QR code URL storage
  • Cuts API: api/src/modules/map/cuts/

    • Cut CRUD operations
    • GeoJSON polygon storage
    • Point-in-polygon filtering
  • Locations API: api/src/modules/map/locations/

    • Location CRUD with cut filtering
    • Address relations
    • Support level tracking

Frontend Documentation

  • Walk Sheet Page: admin/src/pages/WalkSheetPage.tsx

    • Cut selection dropdown
    • Location table rendering
    • QR code display
    • Print functionality
  • Cut Export Page: admin/src/pages/CutExportPage.tsx

    • Statistics calculation
    • CSV export
    • Print layout
  • Map Settings Page: admin/src/pages/MapSettingsPage.tsx

    • Walk sheet configuration form
    • QR code URL/label inputs
    • Settings persistence

Database Documentation

  • Models: api/prisma/schema.prisma
    • MapSettings (singleton)
    • Cut (geojson polygon)
    • Location (geocoded addresses)
    • Address (unit-level data)

External Resources