The AI Trades
Scheduling & Dispatch

Smart Dispatch: Auto-Score Tech-Job Matches

Replit Build Full day
ServiceTitanJobberGoogle Maps

The Problem

Dispatcher assigns by gut feel. The wrong tech drives 45 minutes when a closer one could handle it. This automation scores every tech-job match on distance, skills, certifications, current workload, and customer history, then presents the top 3 options with reasoning.

How It Works

Input

Open jobs + tech profiles (skills, certs, GPS, load)

Transformation

Algorithm scores every match on: distance, skill, certs, load, customer history. Top 3 with reasoning.

Output

Recommended assignments. Dispatcher approves or overrides with tribal knowledge.

PRD

# Product Requirements Document

Recipe 098 -- Smart Dispatch: Auto-Score Tech-Job Matches

THE AI TRADES Platform

---

Recipe Slug: `smart-dispatch-tech-matching`

Recipe Number: 098

Difficulty: Replit Build

Time Estimate: Full day

Category: Scheduling & Dispatch

Software Required: ServiceTitan, Jobber, Google Maps

Roles: Dispatcher, Owner

Trades: HVAC, Plumbing, Electrical

Principles Applied: Make the Invisible Visible, Replace the Boring 80%

---

Reusable Modules Referenced:

  • Module 0: UX Philosophy (`modules/ux-philosophy-module.md`)
  • Module 9: Onboarding Wizard (`modules/onboarding-wizard-module.md`)
  • Module 10: Settings Panel (`modules/settings-panel-module.md`)
  • Module 11: Notification / Toast (`modules/notification-toast-module.md`)
  • Module 12: Data Table / List View (`modules/data-table-list-view-module.md`)
  • Module 15: Scheduling & Dispatch (`modules/scheduling-dispatch-module.md`)
  • Module 16: Map / Service Area (`modules/map-service-area-module.md`)

Integration Docs (include with build):

  • Home Service CRM APIs (`integrations/home-service-crm-apis.md`) -- ServiceTitan, Jobber API access
  • PostgreSQL Database -- database, auth
  • Google Maps / Geocoding (via Google Maps Platform API)

---

Table of Contents

1. Recipe Overview

2. The Problem

3. The Solution

4. Prerequisites

5. Architecture and Data Model

6. Key Screens / UI

7. Step-by-Step Build Instructions

8. Deployment on Replit

9. Testing and Validation

10. Example Output

11. Quick Reference Card

---

1. Recipe Overview

Your dispatcher has 6 open jobs and 4 available techs. She looks at the board, looks at the job types, thinks about who can handle what, and makes a gut call. She sends Tech A to a job 45 minutes away because she forgot Tech C was 10 minutes from that address. She puts the junior on a high-ticket replacement because the senior was already assigned to a $150 diagnostic. Nobody realized the senior would have closed a $9,000 sale on that replacement if he had been the one in the home.

This happens every day. Dispatchers are juggling too many variables in their head: distance, skill level, certification requirements, current workload, revenue potential, customer history. No human can optimize across all of those dimensions for every job every day. The result is wasted drive time, mismatched skill levels, and missed revenue opportunities.

This recipe builds a dispatch scoring application that pulls open jobs and available techs from your CRM (ServiceTitan or Jobber), calculates drive time via Google Maps, scores every possible tech-job combination on four weighted factors, and presents a ranked recommendation list. The dispatcher still makes the final call, but now she sees the data instead of guessing.

Input: Open jobs from your CRM + available tech profiles (skills, certifications, location, current load) + GPS/address data.

Output: Scored match list per job. "Tech A: 92 (3 mi, certified, light load). Tech B: 67 (12 mi, capable)." Dispatcher clicks to assign.

---

2. The Problem

Gut-Feel Dispatch Is Costing You $2,000-$5,000 Per Week

Every bad dispatch decision has a measurable cost. Wrong tech, wrong job, wrong sequence. The damage shows up in three places:

1. Wasted drive time.

ScenarioTime WastedCost at $55/hr loaded rate
Tech drives 45 min when another was 10 min away70 min round trip difference$64
This happens 2-3 times per day across the crew2-3.5 hours/day$110-$193
Weekly total (5 days)10-17.5 hours$550-$963

2. Skill mismatches.

ScenarioImpact
Junior sent to a complex diagnosticMisdiagnoses, callback, second truck roll. Cost: $300-$600
Senior sent to a simple filter change$45/hr tech doing a job a $25/hr apprentice could handle
Uncertified tech arrives for a job requiring EPA 608Cannot legally perform the work. Reschedule. Customer furious.

3. Lost revenue from wrong tech on high-value jobs.

The biggest hidden cost. Your top closer has a 65% conversion rate on replacements. Your average tech converts at 25%. When the dispatcher puts the average tech on a $10,000 replacement opportunity because the closer was assigned to a $200 repair, you just lost $4,000 in expected revenue ($10,000 x 40% conversion gap).

Combined weekly cost of gut-feel dispatch:

Cost CategoryLow EstimateHigh Estimate
Excess drive time$550$963
Skill mismatch callbacks$300$1,200
Revenue loss (wrong tech on high-value)$1,000$4,000
Total weekly cost$1,850$6,163

Your dispatcher is not bad at her job. She is doing mental math across too many variables with incomplete information. This app gives her complete information.

---

3. The Solution

A web application that syncs with your CRM, scores every tech-job combination, and presents ranked recommendations for each open job.

How it works:

1. Pulls open/unassigned jobs from ServiceTitan or Jobber via API. Each job has: address, job type, estimated value, required skills, time window.

2. Pulls available techs with their profiles: current location (last job address or GPS), skills and certifications, current day's load (jobs already assigned), historical close rate by job type.

3. Calculates drive time from each tech's current location to each job address using Google Maps Distance Matrix API.

4. Scores each tech-job pair on four weighted factors:

  • Distance score (0-100): closer is better
  • Skill fit score (0-100): certified and experienced is better
  • Revenue opportunity score (0-100): best closer on highest-value jobs
  • Workload balance score (0-100): lighter current load is better

5. Generates a composite score for each pair and ranks them.

6. Dispatcher reviews the recommendations and assigns with one click.

7. Assignment pushes back to the CRM so the tech sees it on their dispatch board.

What changes:

MetricBefore (Gut Feel)After (Scored Dispatch)
Average drive time per job25-40 min12-20 min
Skill mismatch callbacks/week2-40-1
Top closer on high-value jobs~40% of the time~85% of the time
Dispatch decision time3-5 min per job30 seconds per job
Dispatcher confidence"I think this is right""The numbers say this is right"

---

4. Prerequisites

Before you start, gather these:

  • [ ] Replit account (free tier for build; Replit Deployments for production)
  • [ ] PostgreSQL database for database and auth
  • [ ] Google Maps Platform API key with Distance Matrix API enabled ($5-$15/month for typical dispatch volume)
  • [ ] CRM API access (one of the following):
  • ServiceTitan API key (requires ServiceTitan partnership or developer account)
  • Jobber API key (available on Connect plan and above)
  • Or: manual CSV import if API access is not available yet
  • [ ] Tech profiles ready: a list of your techs with their skills, certifications, and job type capabilities
  • [ ] Historical data (optional but recommended): past 90 days of completed jobs with tech assigned and revenue closed, for calculating close rates

Monthly costs:

ServiceCost
Google Maps Distance Matrix$5-$15/mo (500-1500 lookups at $0.01/element)
PostgreSQL databaseFree tier
ReplitFree tier or $7/mo for always-on
OpenAI (optional, for job classification)$2-$5/mo
Total$7-$27/mo

One prevented 45-minute wasted drive per day pays for the entire stack in the first week.

---

5. Architecture and Data Model

System Architecture

```

[CRM: ServiceTitan / Jobber]

|

v (API sync every 5 min)

[Replit Web App]

|

+---> [Express API Server]

| |

| +---> [PostgreSQL]

| | - jobs table

| | - techs table

| | - tech_skills table

| | - assignments table

| | - score_logs table

| |

| +---> [Google Maps Distance Matrix API]

| | - drive time calculations

| |

| +---> [Scoring Engine]

| - distance score

| - skill fit score

| - revenue opportunity score

| - workload balance score

|

v

[React Frontend - Dispatch Board]

  • Map view with tech positions and job locations
  • Ranked recommendation list per job
  • One-click assign

```

Data Model

Table: techs

ColumnTypeDescription
iduuidPrimary key
crm_idtextID from ServiceTitan/Jobber
nametext"Marcus Johnson"
roletext"senior_tech", "tech", "apprentice"
primary_tradetext"hvac", "plumbing", "electrical"
hourly_ratenumericLoaded cost rate for ROI calculations
current_latnumericLast known latitude
current_lngnumericLast known longitude
current_addresstextLast job address or home base
is_availablebooleanCurrently clocked in and not on a job
jobs_todayintegerNumber of jobs already assigned today
avg_close_ratenumericHistorical close rate on sold jobs (0.0-1.0)
close_rate_replacementsnumericClose rate on replacement/high-ticket jobs
close_rate_repairsnumericClose rate on repair jobs
updated_attimestamptzLast sync from CRM

Table: tech_skills

ColumnTypeDescription
iduuidPrimary key
tech_iduuidFK to techs
skilltext"ac_install", "furnace_repair", "tankless_wh", "electrical_panel"
certificationtextNullable. "EPA 608 Universal", "NATE", "Master Plumber"
proficiencytext"certified", "experienced", "capable", "learning"

Table: jobs

ColumnTypeDescription
iduuidPrimary key
crm_idtextID from ServiceTitan/Jobber
customer_nametext"Sarah Martinez"
addresstextFull street address
latnumericGeocoded latitude
lngnumericGeocoded longitude
job_typetext"ac_repair", "ac_install", "water_heater", "drain_cleaning"
job_categorytext"diagnostic", "repair", "replacement", "maintenance"
estimated_valuenumericDollar estimate for the job
required_skillstext[]Skills needed: ["ac_install", "epa_608"]
time_window_starttimestamptzEarliest arrival time
time_window_endtimestamptzLatest arrival time
statustext"unassigned", "assigned", "in_progress", "completed"
assigned_tech_iduuidFK to techs (null until assigned)
prioritytext"emergency", "high", "normal", "low"
created_attimestamptzWhen the job entered the system

Table: assignments

ColumnTypeDescription
iduuidPrimary key
job_iduuidFK to jobs
tech_iduuidFK to techs
composite_scorenumericThe final weighted score (0-100)
distance_scorenumericDistance component (0-100)
skill_scorenumericSkill fit component (0-100)
revenue_scorenumericRevenue opportunity component (0-100)
workload_scorenumericWorkload balance component (0-100)
drive_time_minutesnumericEstimated drive time
drive_distance_milesnumericEstimated distance
assigned_byuuidFK to dispatcher's user profile
assigned_attimestamptzWhen the assignment was made
was_recommendedbooleanWhether this tech was the top recommendation

Table: score_weights

ColumnTypeDescription
iduuidPrimary key
company_iduuidFor multi-company support
distance_weightnumericDefault: 0.30
skill_weightnumericDefault: 0.30
revenue_weightnumericDefault: 0.25
workload_weightnumericDefault: 0.15
updated_attimestamptzLast updated

Relationships

```

techs.id --> tech_skills.tech_id

techs.id --> jobs.assigned_tech_id

techs.id --> assignments.tech_id

jobs.id --> assignments.job_id

```

---

6. Key Screens / UI

Screen 1: Dispatch Board (Primary Screen)

Split-panel layout. Left side shows the unassigned jobs list. Right side shows the map.

  • Left panel: Job Queue. Cards for each unassigned job showing: customer name, job type badge, estimated value, time window, priority indicator (red for emergency, orange for high, gray for normal). Jobs sorted by priority then time window.
  • Right panel: Map. Google Map showing tech positions (blue markers with initials) and job locations (orange pins). Lines drawn between a tech and job when hovering over a recommendation.
  • Clicking a job card expands it to show the scored recommendation list:
  • Each tech listed with their composite score, individual factor scores, drive time, and distance
  • Top recommendation highlighted in green
  • "Assign" button next to each tech
  • Score breakdown visible: "Distance: 85 | Skill: 95 | Revenue: 70 | Load: 90"

Screen 2: Recommendations Detail

When a dispatcher clicks a job, this panel slides in from the right.

  • Job details at top: customer, address, type, value, time window
  • Recommended techs listed in score order:
  • Tech name, role, and photo/avatar
  • Composite score as a large number with color coding (green 80+, yellow 60-79, red below 60)
  • Four mini progress bars for each scoring factor
  • Drive time and distance
  • Current load: "2 jobs today, next available at 1:30 PM"
  • Relevant certifications/skills listed as badges
  • "Assign" button pushes the assignment to the CRM and moves the job to "Assigned" status
  • "Override" button lets the dispatcher assign someone not recommended, with a required reason field (for tracking override patterns)

Screen 3: Tech Profiles

Admin page for managing tech profiles and skills.

  • Data table: Name, Role, Trade, Skills count, Certifications, Avg Close Rate
  • Click a tech to see their full profile:
  • Skills and certifications with proficiency levels
  • Close rate by job category (chart or table)
  • Assignment history for the last 30 days
  • Average customer rating (if available from CRM)
  • "Edit Skills" button to add/remove skills and update proficiency
  • "Sync from CRM" button to pull latest tech data

Screen 4: Scoring Settings

Owner-accessible settings page.

  • Four sliders for adjusting score weights (Distance, Skill Fit, Revenue, Workload). All four must sum to 1.0.
  • Presets: "Distance Priority" (for companies spread across a large area), "Revenue Priority" (for companies focused on upselling), "Balanced" (default)
  • Distance thresholds: define what counts as "close" (under 10 min), "moderate" (10-25 min), "far" (25+ min)
  • Skill proficiency mapping: define which proficiency levels qualify for each job type
  • Save button with confirmation

Screen 5: Analytics Dashboard

Weekly and monthly views.

  • Average composite score of actual assignments (are dispatchers following recommendations?)
  • Override rate: what percentage of assignments went against the top recommendation?
  • Drive time saved: estimated minutes saved compared to random/sequential assignment
  • Revenue impact: jobs assigned to top closers vs. average techs, with conversion comparison
  • Heat map of job locations vs. tech home bases (visual service area coverage)

---

7. Step-by-Step Build Instructions

Step 1: Set Up PostgreSQL Database

1. Create a PostgreSQL database (local or hosted)

2. Run the schema creation SQL:

```sql

create table techs (

id uuid primary key default gen_random_uuid(),

crm_id text unique,

name text not null,

role text not null default 'tech' check (role in ('senior_tech', 'tech', 'apprentice')),

primary_trade text not null,

hourly_rate numeric default 45,

current_lat numeric,

current_lng numeric,

current_address text,

is_available boolean default true,

jobs_today integer default 0,

avg_close_rate numeric default 0.35,

close_rate_replacements numeric default 0.30,

close_rate_repairs numeric default 0.40,

updated_at timestamptz default now()

);

create table tech_skills (

id uuid primary key default gen_random_uuid(),

tech_id uuid references techs(id) on delete cascade,

skill text not null,

certification text,

proficiency text not null default 'capable' check (proficiency in ('certified', 'experienced', 'capable', 'learning'))

);

create table jobs (

id uuid primary key default gen_random_uuid(),

crm_id text unique,

customer_name text not null,

address text not null,

lat numeric,

lng numeric,

job_type text not null,

job_category text not null default 'repair' check (job_category in ('diagnostic', 'repair', 'replacement', 'maintenance')),

estimated_value numeric default 0,

required_skills text[] default '{}',

time_window_start timestamptz,

time_window_end timestamptz,

status text not null default 'unassigned' check (status in ('unassigned', 'assigned', 'in_progress', 'completed', 'cancelled')),

assigned_tech_id uuid references techs(id),

priority text default 'normal' check (priority in ('emergency', 'high', 'normal', 'low')),

created_at timestamptz default now()

);

create table assignments (

id uuid primary key default gen_random_uuid(),

job_id uuid references jobs(id) on delete cascade,

tech_id uuid references techs(id),

composite_score numeric not null,

distance_score numeric not null,

skill_score numeric not null,

revenue_score numeric not null,

workload_score numeric not null,

drive_time_minutes numeric,

drive_distance_miles numeric,

assigned_by uuid,

assigned_at timestamptz default now(),

was_recommended boolean default true

);

create table score_weights (

id uuid primary key default gen_random_uuid(),

company_id uuid,

distance_weight numeric default 0.30,

skill_weight numeric default 0.30,

revenue_weight numeric default 0.25,

workload_weight numeric default 0.15,

updated_at timestamptz default now()

);

-- Indexes

create index idx_jobs_status on jobs(status);

create index idx_jobs_assigned_tech on jobs(assigned_tech_id);

create index idx_techs_available on techs(is_available);

create index idx_tech_skills_tech on tech_skills(tech_id);

-- Insert default weights

insert into score_weights (distance_weight, skill_weight, revenue_weight, workload_weight)

values (0.30, 0.30, 0.25, 0.15);

```

Step 2: Build the Scoring Engine

This is the core of the application. Each tech-job pair gets four scores, weighted and combined.

```typescript

// server/services/scoring-engine.ts

interface Tech {

id: string;

name: string;

role: string;

current_lat: number;

current_lng: number;

jobs_today: number;

avg_close_rate: number;

close_rate_replacements: number;

close_rate_repairs: number;

skills: TechSkill[];

}

interface TechSkill {

skill: string;

certification: string | null;

proficiency: string;

}

interface Job {

id: string;

lat: number;

lng: number;

job_type: string;

job_category: string;

estimated_value: number;

required_skills: string[];

priority: string;

}

interface ScoreWeights {

distance_weight: number;

skill_weight: number;

revenue_weight: number;

workload_weight: number;

}

interface DriveData {

duration_minutes: number;

distance_miles: number;

}

interface TechJobScore {

tech_id: string;

tech_name: string;

job_id: string;

composite_score: number;

distance_score: number;

skill_score: number;

revenue_score: number;

workload_score: number;

drive_time_minutes: number;

drive_distance_miles: number;

}

export function calculateDistanceScore(driveMinutes: number): number {

// 0-10 min = 100, 10-20 min = 80-60, 20-35 min = 60-30, 35+ min = 30-0

if (driveMinutes <= 10) return 100;

if (driveMinutes <= 20) return 100 - ((driveMinutes - 10) * 4);

if (driveMinutes <= 35) return 60 - ((driveMinutes - 20) * 2);

if (driveMinutes <= 60) return 30 - ((driveMinutes - 35) * 1.2);

return 0;

}

export function calculateSkillScore(tech: Tech, job: Job): number {

const requiredSkills = job.required_skills;

if (requiredSkills.length === 0) return 80; // No specific skills required

let totalScore = 0;

let matchedCount = 0;

for (const required of requiredSkills) {

const techSkill = tech.skills.find((s) => s.skill === required);

if (!techSkill) {

// Tech does not have this skill at all

totalScore += 0;

} else {

matchedCount++;

switch (techSkill.proficiency) {

case "certified":

totalScore += 100;

break;

case "experienced":

totalScore += 80;

break;

case "capable":

totalScore += 50;

break;

case "learning":

totalScore += 20;

break;

}

}

}

if (matchedCount === 0) return 0; // Cannot do the job at all

return Math.round(totalScore / requiredSkills.length);

}

export function calculateRevenueScore(tech: Tech, job: Job): number {

// For high-value jobs, we want the best closer

// For low-value jobs, revenue matching matters less

const value = job.estimated_value;

if (value < 500) {

// Low-value job: any tech is fine, score based on not wasting top closers

return tech.role === "apprentice" ? 90 : tech.role === "tech" ? 70 : 50;

}

// High-value job: score based on close rate for this category

const relevantCloseRate =

job.job_category === "replacement"

? tech.close_rate_replacements

: tech.close_rate_repairs;

// Scale close rate to 0-100 score

// A 0.65 close rate = 100, 0.50 = 77, 0.35 = 54, 0.20 = 31

const score = Math.min(100, Math.round((relevantCloseRate / 0.65) * 100));

// Bonus for high-value jobs: amplify the score difference

if (value > 5000) {

return Math.min(100, score + 10);

}

return score;

}

export function calculateWorkloadScore(tech: Tech, maxJobsPerDay: number = 6): number {

// Fewer jobs today = higher score (more capacity)

const jobsRemaining = maxJobsPerDay - tech.jobs_today;

if (jobsRemaining <= 0) return 0;

return Math.round((jobsRemaining / maxJobsPerDay) * 100);

}

export async function scoreAllPairs(

techs: Tech[],

jobs: Job[],

weights: ScoreWeights,

driveDataMap: Map<string, DriveData>

): Promise<Map<string, TechJobScore[]>> {

const results = new Map<string, TechJobScore[]>();

for (const job of jobs) {

const scores: TechJobScore[] = [];

for (const tech of techs) {

const driveKey = `${tech.id}-${job.id}`;

const driveData = driveDataMap.get(driveKey);

if (!driveData) {

console.warn(`[Scoring] No drive data for ${tech.name} -> ${job.id}`);

continue;

}

const distanceScore = calculateDistanceScore(driveData.duration_minutes);

const skillScore = calculateSkillScore(tech, job);

const revenueScore = calculateRevenueScore(tech, job);

const workloadScore = calculateWorkloadScore(tech);

// Skip techs with zero skill score (they cannot do the job)

if (skillScore === 0) {

console.log(`[Scoring] ${tech.name} skipped for job ${job.id}: no matching skills`);

continue;

}

const composite =

distanceScore * weights.distance_weight +

skillScore * weights.skill_weight +

revenueScore * weights.revenue_weight +

workloadScore * weights.workload_weight;

scores.push({

tech_id: tech.id,

tech_name: tech.name,

job_id: job.id,

composite_score: Math.round(composite),

distance_score: Math.round(distanceScore),

skill_score: Math.round(skillScore),

revenue_score: Math.round(revenueScore),

workload_score: Math.round(workloadScore),

drive_time_minutes: driveData.duration_minutes,

drive_distance_miles: driveData.distance_miles,

});

}

// Sort by composite score descending

scores.sort((a, b) => b.composite_score - a.composite_score);

results.set(job.id, scores);

console.log(

`[Scoring] Job ${job.id}: Top match = ${scores[0]?.tech_name} (${scores[0]?.composite_score})`

);

}

return results;

}

```

Step 3: Build the Google Maps Distance Service

```typescript

// server/services/distance.ts

interface DistanceResult {

origin_tech_id: string;

destination_job_id: string;

duration_minutes: number;

distance_miles: number;

}

export async function getDistanceMatrix(

techs: Array<{ id: string; current_lat: number; current_lng: number }>,

jobs: Array<{ id: string; lat: number; lng: number }>

): Promise<Map<string, { duration_minutes: number; distance_miles: number }>> {

console.log(

`[Distance] Calculating distances for ${techs.length} techs x ${jobs.length} jobs`

);

const origins = techs.map((t) => `${t.current_lat},${t.current_lng}`).join("|");

const destinations = jobs.map((j) => `${j.lat},${j.lng}`).join("|");

const url = `https://maps.googleapis.com/maps/api/distancematrix/json?origins=${origins}&destinations=${destinations}&key=${process.env.GOOGLE_MAPS_API_KEY}&units=imperial`;

const response = await fetch(url);

const data = await response.json();

const results = new Map<string, { duration_minutes: number; distance_miles: number }>();

if (data.status !== "OK") {

console.error(`[Distance] API error: ${data.status}`);

return results;

}

for (let i = 0; i < techs.length; i++) {

for (let j = 0; j < jobs.length; j++) {

const element = data.rows[i].elements[j];

const key = `${techs[i].id}-${jobs[j].id}`;

if (element.status === "OK") {

results.set(key, {

duration_minutes: Math.round(element.duration.value / 60),

distance_miles: Math.round(element.distance.value / 1609.34 * 10) / 10,

});

} else {

console.warn(`[Distance] No route for ${key}: ${element.status}`);

results.set(key, { duration_minutes: 999, distance_miles: 999 });

}

}

}

console.log(`[Distance] Calculated ${results.size} distance pairs`);

return results;

}

```

Cost note: Google Maps Distance Matrix charges $5 per 1,000 elements. 4 techs x 6 jobs = 24 elements per batch. Running this 20 times per day = 480 elements = well under $5/month.

Step 4: Build the CRM Sync Service

```typescript

// server/services/crm-sync.ts

import { db } from "../lib/database";

// ServiceTitan sync (adapt for Jobber by changing endpoints)

export async function syncJobsFromCRM() {

console.log("[CRM Sync] Fetching unassigned jobs from ServiceTitan...");

const response = await fetch(

"https://api.servicetitan.io/dispatch/v2/jobs?status=Unassigned",

{

headers: {

Authorization: `Bearer ${process.env.SERVICETITAN_API_KEY}`,

"ST-App-Key": process.env.SERVICETITAN_APP_KEY!,

},

}

);

const data = await response.json();

console.log(`[CRM Sync] Found ${data.data?.length || 0} unassigned jobs`);

for (const job of data.data || []) {

// Geocode the address if we do not have lat/lng

let lat = job.location?.latitude;

let lng = job.location?.longitude;

if (!lat || !lng) {

const geo = await geocodeAddress(job.location?.address?.street);

lat = geo.lat;

lng = geo.lng;

}

await db.query(

`INSERT INTO jobs (crm_id, customer_name, address, lat, lng, job_type, job_category, estimated_value, required_skills, time_window_start, time_window_end, status, priority)

VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)

ON CONFLICT (crm_id) DO UPDATE SET

customer_name = EXCLUDED.customer_name, address = EXCLUDED.address, lat = EXCLUDED.lat, lng = EXCLUDED.lng,

job_type = EXCLUDED.job_type, estimated_value = EXCLUDED.estimated_value, status = EXCLUDED.status, priority = EXCLUDED.priority`,

[

job.id.toString(), job.customer?.name || "Unknown", formatAddress(job.location?.address),

lat, lng, mapJobType(job.type?.name), mapJobCategory(job.type?.name),

job.summary?.total || 0, inferRequiredSkills(job.type?.name),

job.schedule?.start, job.schedule?.end, "unassigned",

job.priority === "Urgent" ? "emergency" : "normal",

]

);

}

console.log("[CRM Sync] Job sync complete");

}

export async function syncTechsFromCRM() {

console.log("[CRM Sync] Fetching tech roster from ServiceTitan...");

const response = await fetch(

"https://api.servicetitan.io/dispatch/v2/technicians?active=true",

{

headers: {

Authorization: `Bearer ${process.env.SERVICETITAN_API_KEY}`,

"ST-App-Key": process.env.SERVICETITAN_APP_KEY!,

},

}

);

const data = await response.json();

console.log(`[CRM Sync] Found ${data.data?.length || 0} active techs`);

for (const tech of data.data || []) {

await db.query(

`INSERT INTO techs (crm_id, name, role, primary_trade, is_available)

VALUES ($1, $2, $3, $4, $5)

ON CONFLICT (crm_id) DO UPDATE SET

name = EXCLUDED.name, role = EXCLUDED.role, is_available = EXCLUDED.is_available`,

[tech.id.toString(), tech.name, mapTechRole(tech), tech.trade || "hvac", tech.status === "Available"]

);

}

console.log("[CRM Sync] Tech sync complete");

}

async function geocodeAddress(address: string): Promise<{ lat: number; lng: number }> {

const encoded = encodeURIComponent(address);

const response = await fetch(

`https://maps.googleapis.com/maps/api/geocode/json?address=${encoded}&key=${process.env.GOOGLE_MAPS_API_KEY}`

);

const data = await response.json();

if (data.results?.[0]) {

return data.results[0].geometry.location;

}

console.warn(`[Geocode] Could not geocode: ${address}`);

return { lat: 0, lng: 0 };

}

```

Step 5: Build the API Routes

```typescript

// server/routes/dispatch.ts

import { Router } from "express";

import { db } from "../lib/database";

import { scoreAllPairs } from "../services/scoring-engine";

import { getDistanceMatrix } from "../services/distance";

const router = Router();

// Get scored recommendations for all unassigned jobs

router.get("/api/dispatch/recommendations", async (req, res) => {

console.log("[Dispatch] Generating recommendations...");

// Fetch unassigned jobs

const { rows: jobs } = await db.query(

`SELECT * FROM jobs WHERE status = 'unassigned'`

);

// Fetch available techs with skills

const { rows: techs } = await db.query(

`SELECT t., json_agg(ts.) as tech_skills FROM techs t

LEFT JOIN tech_skills ts ON ts.tech_id = t.id

WHERE t.is_available = true GROUP BY t.id`

);

if (!jobs?.length || !techs?.length) {

return res.json({ jobs: [], recommendations: {} });

}

// Fetch score weights

const { rows: [weights] } = await db.query(

`SELECT * FROM score_weights LIMIT 1`

);

// Get drive times

const driveData = await getDistanceMatrix(

techs.map((t) => ({ id: t.id, current_lat: t.current_lat, current_lng: t.current_lng })),

jobs.map((j) => ({ id: j.id, lat: j.lat, lng: j.lng }))

);

// Map tech skills into the expected structure

const techsWithSkills = techs.map((t) => ({

...t,

skills: t.tech_skills || [],

}));

// Score all pairs

const recommendations = await scoreAllPairs(techsWithSkills, jobs, weights, driveData);

// Convert Map to plain object for JSON response

const recObject: Record<string, any[]> = {};

recommendations.forEach((scores, jobId) => {

recObject[jobId] = scores;

});

console.log(`[Dispatch] Generated recommendations for ${jobs.length} jobs`);

return res.json({

jobs,

techs,

recommendations: recObject,

});

});

// Assign a tech to a job

router.post("/api/dispatch/assign", async (req, res) => {

const { job_id, tech_id, scores, was_recommended, override_reason } = req.body;

console.log(`[Dispatch] Assigning tech ${tech_id} to job ${job_id}`);

// Update job status

const { rowCount } = await db.query(

`UPDATE jobs SET status = 'assigned', assigned_tech_id = $1 WHERE id = $2`,

[tech_id, job_id]

);

if (!rowCount) {

return res.status(500).json({ error: "Failed to update job" });

}

// Log the assignment

await db.query(

`INSERT INTO assignments (job_id, tech_id, composite_score, distance_score, skill_score, revenue_score, workload_score, drive_time_minutes, drive_distance_miles, assigned_by, was_recommended)

VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,

[job_id, tech_id, scores.composite_score, scores.distance_score, scores.skill_score, scores.revenue_score, scores.workload_score, scores.drive_time_minutes, scores.drive_distance_miles, req.user?.id, was_recommended]

);

// Update tech workload

await db.query(

`UPDATE techs SET jobs_today = jobs_today + 1 WHERE id = $1`,

[tech_id]

);

// TODO: Push assignment back to CRM via API

console.log(`[Dispatch] Assignment complete. Score: ${scores.composite_score}`);

return res.json({ success: true });

});

export default router;

```

Step 6: Build the Dispatch Board UI

The dispatch board is the primary screen. Build it with a two-column layout on desktop, stacked on mobile.

```tsx

// client/src/pages/DispatchBoard.tsx

import { useState, useEffect } from "react";

interface Recommendation {

tech_id: string;

tech_name: string;

composite_score: number;

distance_score: number;

skill_score: number;

revenue_score: number;

workload_score: number;

drive_time_minutes: number;

drive_distance_miles: number;

}

export default function DispatchBoard() {

const [jobs, setJobs] = useState([]);

const [recommendations, setRecommendations] = useState({});

const [selectedJob, setSelectedJob] = useState(null);

const [loading, setLoading] = useState(true);

useEffect(() => {

loadRecommendations();

// Refresh every 5 minutes

const interval = setInterval(loadRecommendations, 5 60 1000);

return () => clearInterval(interval);

}, []);

async function loadRecommendations() {

console.log("[UI] Loading dispatch recommendations...");

const res = await fetch("/api/dispatch/recommendations");

const data = await res.json();

setJobs(data.jobs);

setRecommendations(data.recommendations);

setLoading(false);

}

async function assignTech(jobId: string, rec: Recommendation) {

await fetch("/api/dispatch/assign", {

method: "POST",

headers: { "Content-Type": "application/json" },

body: JSON.stringify({

job_id: jobId,

tech_id: rec.tech_id,

scores: rec,

was_recommended: true,

}),

});

await loadRecommendations();

setSelectedJob(null);

}

function scoreColor(score: number): string {

if (score >= 80) return "text-green-600";

if (score >= 60) return "text-yellow-600";

return "text-red-600";

}

return (

<div className="flex h-screen bg-gray-50">

{/ Job Queue /}

<div className="w-1/2 overflow-y-auto p-4 border-r">

<h2 className="text-lg font-bold mb-4">

Unassigned Jobs ({jobs.length})

</h2>

{jobs.map((job) => (

<div

key={job.id}

onClick={() => setSelectedJob(job.id)}

className={`border rounded-lg p-4 mb-3 cursor-pointer ${

selectedJob === job.id ? "border-blue-500 bg-blue-50" : "bg-white"

}`}

>

<div className="flex justify-between items-start">

<div>

<p className="font-semibold">{job.customer_name}</p>

<p className="text-sm text-gray-600">{job.address}</p>

</div>

<span className="text-lg font-bold text-green-700">

${job.estimated_value?.toLocaleString()}

</span>

</div>

<div className="flex gap-2 mt-2">

<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">

{job.job_type}

</span>

<span className="text-xs bg-gray-100 px-2 py-0.5 rounded">

{job.job_category}

</span>

{job.priority === "emergency" && (

<span className="text-xs bg-red-100 text-red-800 px-2 py-0.5 rounded">

EMERGENCY

</span>

)}

</div>

{/ Show recommendations when selected /}

{selectedJob === job.id && recommendations[job.id] && (

<div className="mt-4 border-t pt-3">

<p className="text-sm font-semibold mb-2">Recommended Techs:</p>

{recommendations[job.id].map((rec, idx) => (

<div

key={rec.tech_id}

className={`flex items-center justify-between p-2 rounded mb-1 ${

idx === 0 ? "bg-green-50 border border-green-200" : "bg-gray-50"

}`}

>

<div>

<span className="font-medium">{rec.tech_name}</span>

<span className="text-sm text-gray-500 ml-2">

{rec.drive_distance_miles} mi / {rec.drive_time_minutes} min

</span>

</div>

<div className="flex items-center gap-3">

<span className={`text-lg font-bold ${scoreColor(rec.composite_score)}`}>

{rec.composite_score}

</span>

<button

onClick={(e) => { e.stopPropagation(); assignTech(job.id, rec); }}

className="bg-blue-600 text-white text-sm px-3 py-1 rounded"

>

Assign

</button>

</div>

</div>

))}

</div>

)}

</div>

))}

</div>

{/ Map Panel /}

<div className="w-1/2 relative">

{/ Google Map component goes here /}

<div id="dispatch-map" className="w-full h-full" />

</div>

</div>

);

}

```

Step 7: Set Up the CRM Sync Cron

```typescript

// server/cron/sync.ts

import cron from "node-cron";

import { syncJobsFromCRM, syncTechsFromCRM } from "../services/crm-sync";

// Sync jobs every 5 minutes during business hours (6 AM - 8 PM)

cron.schedule("/5 6-20 * 1-6", async () => {

console.log("[Cron] Starting CRM sync...");

try {

await syncJobsFromCRM();

await syncTechsFromCRM();

console.log("[Cron] CRM sync complete");

} catch (err) {

console.error(`[Cron] Sync failed: ${err.message}`);

}

});

```

---

8. Deployment on Replit

Step 1: Environment Variables

In Replit Secrets, add:

SecretValue
DATABASE_URLYour PostgreSQL connection string
GOOGLE_MAPS_API_KEYGoogle Maps Platform key (Distance Matrix + Geocoding enabled)
SERVICETITAN_API_KEYServiceTitan API bearer token (or JOBBER_API_KEY)
SERVICETITAN_APP_KEYServiceTitan app key

Step 2: Build and Deploy

1. `.replit` file:

```

run = "npm run dev"

```

2. For production:

```

run = "npm run build && npm start"

```

3. Click Deploy > Autoscale

4. Build command: `npm run build`

5. Run command: `npm start`

Step 3: Configure CRM Webhook (Optional)

Instead of polling every 5 minutes, configure a webhook in ServiceTitan or Jobber to POST to your app when a new job is created. This gives real-time updates.

Webhook endpoint: `https://your-app.replit.app/api/webhooks/new-job`

---

9. Testing and Validation

Test 1: Scoring Engine Unit Tests

Create test data with known expected outcomes:

TechLocationSkillsClose RateJobs Today
Marcus (Senior)3 mi from jobac_install (certified)0.651
Danny (Tech)12 mi from jobac_install (capable)0.353
Kim (Apprentice)5 mi from jobac_repair (learning)0.200

Job: AC Install, estimated $8,500, requires ac_install skill.

Expected ranking: Marcus > Danny > Kim. Marcus should score highest due to proximity + certification + close rate despite having 1 job already.

Pass criteria: Scoring engine produces expected ranking for 5 different test scenarios.

Test 2: Distance API Integration

1. Enter two known addresses

2. Verify the returned drive time is within 5 minutes of Google Maps manual check

3. Test with an invalid address and verify graceful handling

Pass criteria: Drive times match manual Google Maps lookup within 5-minute tolerance.

Test 3: End-to-End Dispatch Flow

1. Create 3 test jobs and 3 test techs in the database

2. Open the dispatch board

3. Verify all jobs appear with scored recommendations

4. Click "Assign" on a recommendation

5. Verify: job moves to "Assigned" status, assignment logged with scores, tech's jobs_today increments

Pass criteria: Full assign flow completes, all data updates correctly.

Test 4: CRM Sync (if using API)

1. Create a test job in ServiceTitan/Jobber

2. Wait for next sync cycle (5 minutes) or trigger manually

3. Verify the job appears in your app's unassigned queue with correct details

Pass criteria: CRM jobs appear in the app within 5 minutes.

Test 5: Scoring Weight Adjustments

1. Set weights to 100% distance (1.0, 0, 0, 0)

2. Verify the closest tech always ranks first regardless of skill or revenue

3. Set weights to 100% skill (0, 1.0, 0, 0)

4. Verify the most qualified tech ranks first regardless of distance

Pass criteria: Weight adjustments produce expected ranking changes.

Test 6: Edge Cases

  • Zero available techs: app shows "No techs available" message
  • Zero unassigned jobs: app shows "All jobs assigned" message
  • Tech with no GPS location: excluded from scoring with a warning
  • Job with no geocoded address: excluded with a warning

Pass criteria: All edge cases handled without crashes or misleading data.

---

10. Example Output

Scenario: Tuesday Morning, 4 Jobs, 3 Techs

8:15 AM. Lisa, the dispatcher at Peak Comfort HVAC, opens the dispatch board. Four jobs came in overnight and this morning.

Unassigned Jobs:

JobCustomerTypeValuePriority
J-1Patel residenceAC not cooling (diagnostic)$200Normal
J-2Thompson homeAC replacement$9,200High
J-3Rivera condoThermostat install$350Normal
J-4Chen residenceFurnace no heat (diagnostic)$200Emergency

Available Techs:

TechRoleLocationJobs TodayClose Rate (Replacements)
MarcusSeniorShop (base)062%
DannyTechFinishing job in North Dallas128%
KimApprenticeShop (base)0N/A

The system generates recommendations:

Job J-2: AC Replacement ($9,200) -- High Priority

RankTechScoreDistanceSkillRevenueLoadDrive
1Marcus9278 (18 min)100 (certified)95 (62% close)100 (0 jobs)18 min, 11 mi
2Danny5855 (28 min)50 (capable)43 (28% close)83 (1 job)28 min, 19 mi

Lisa sees Marcus is the clear pick for the $9,200 replacement. His close rate is more than double Danny's. The revenue difference: Marcus at 62% = $5,704 expected. Danny at 28% = $2,576 expected. That is a $3,128 gap.

Job J-4: Furnace No Heat -- Emergency

RankTechScoreDistanceSkillRevenueLoadDrive
1Danny8190 (8 min)80 (experienced)5583 (1 job)8 min, 4 mi
2Marcus6278 (18 min)100 (certified)40100 (0 jobs)18 min, 11 mi

Danny is closer and experienced with furnaces. Marcus would be better qualified, but the distance gap and the fact that Marcus is more valuable on the replacement job makes Danny the right call.

Job J-1: AC Diagnostic ($200)

RankTechScoreDistanceSkillRevenueLoadDrive
1Kim7685 (12 min)60 (capable)90 (low-value, save closers)100 (0 jobs)12 min, 7 mi
2Danny5460 (22 min)80 (experienced)5567 (2 jobs after J-4)22 min, 14 mi

Kim handles the low-value diagnostic. No reason to burn the senior or a busy tech on a $200 job.

Lisa assigns all four jobs in under 2 minutes. Every tech is on the right job. Marcus closes the $9,200 replacement at 2 PM. Danny handles the emergency and the Patel diagnostic back-to-back because they are in the same area. Kim does the thermostat install solo.

End of day result: $9,200 replacement sold (would have been a coin flip if Danny had been sent). 38 fewer combined drive minutes. Zero skill mismatches.

---

11. Quick Reference Card

```

SMART DISPATCH: TECH-JOB MATCHING

====================================

WHAT IT DOES:

Scores every tech-job combination on 4 factors.

Recommends the best tech for each open job.

Dispatcher assigns in one click.

SCORING FACTORS:

Distance (30%) - Drive time from tech's current location

Skill Fit (30%) - Certifications + proficiency for this job type

Revenue (25%) - Best closer on highest-value jobs

Workload (15%) - Balance jobs across the crew

HOW TO USE (DISPATCHER):

1. Open the dispatch board

2. Click an unassigned job

3. Review the ranked tech recommendations

4. Click "Assign" on your choice

5. Assignment pushes to the CRM automatically

TECH PROFILES (OWNER):

Keep tech skills and certifications up to date

Review close rates monthly (auto-calculated from CRM data)

Add new skills when techs get certified

SCORING WEIGHTS (OWNER):

Settings > Scoring Weights

Adjust sliders based on your business priority

Presets: Distance Priority, Revenue Priority, Balanced

TECH STACK:

Frontend: React + Tailwind

Backend: Express on Replit

Database: PostgreSQL

Maps: Google Maps Distance Matrix API

CRM Sync: ServiceTitan or Jobber API (5-min intervals)

COST:

Google Maps: $5-$15/mo

PostgreSQL: Free tier

Replit: Free or $7/mo

Total: $7-$27/mo

WHAT THIS SAVES:

Drive time: 30-45 min/day across crew

Skill mismatches: 2-4/week -> near zero

Revenue: right closer on right job = $1,000-$4,000/week

Dispatcher time: 3-5 min/job -> 30 sec/job

```

---

Recipe 098 -- Smart Dispatch: Auto-Score Tech-Job Matches

THE AI TRADES Platform

Difficulty: Replit Build | Time: Full day | Category: Scheduling & Dispatch

RolesDispatcherOwner
IndustriesHVACPlumbingElectrical
PrinciplesMake the Invisible VisibleYour Data is Worth More Than You Think