Smart Dispatch: Auto-Score Tech-Job Matches
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
Open jobs + tech profiles (skills, certs, GPS, load)
Algorithm scores every match on: distance, skill, certs, load, customer history. Top 3 with reasoning.
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
2. The Problem
3. The Solution
5. Architecture and Data Model
7. Step-by-Step Build Instructions
10. Example Output
---
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.
| Scenario | Time Wasted | Cost at $55/hr loaded rate |
|---|---|---|
| Tech drives 45 min when another was 10 min away | 70 min round trip difference | $64 |
| This happens 2-3 times per day across the crew | 2-3.5 hours/day | $110-$193 |
| Weekly total (5 days) | 10-17.5 hours | $550-$963 |
2. Skill mismatches.
| Scenario | Impact |
|---|---|
| Junior sent to a complex diagnostic | Misdiagnoses, 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 608 | Cannot 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 Category | Low Estimate | High 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:
| Metric | Before (Gut Feel) | After (Scored Dispatch) |
|---|---|---|
| Average drive time per job | 25-40 min | 12-20 min |
| Skill mismatch callbacks/week | 2-4 | 0-1 |
| Top closer on high-value jobs | ~40% of the time | ~85% of the time |
| Dispatch decision time | 3-5 min per job | 30 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:
| Service | Cost |
|---|---|
| Google Maps Distance Matrix | $5-$15/mo (500-1500 lookups at $0.01/element) |
| PostgreSQL database | Free tier |
| Replit | Free 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
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| crm_id | text | ID from ServiceTitan/Jobber |
| name | text | "Marcus Johnson" |
| role | text | "senior_tech", "tech", "apprentice" |
| primary_trade | text | "hvac", "plumbing", "electrical" |
| hourly_rate | numeric | Loaded cost rate for ROI calculations |
| current_lat | numeric | Last known latitude |
| current_lng | numeric | Last known longitude |
| current_address | text | Last job address or home base |
| is_available | boolean | Currently clocked in and not on a job |
| jobs_today | integer | Number of jobs already assigned today |
| avg_close_rate | numeric | Historical close rate on sold jobs (0.0-1.0) |
| close_rate_replacements | numeric | Close rate on replacement/high-ticket jobs |
| close_rate_repairs | numeric | Close rate on repair jobs |
| updated_at | timestamptz | Last sync from CRM |
Table: tech_skills
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| tech_id | uuid | FK to techs |
| skill | text | "ac_install", "furnace_repair", "tankless_wh", "electrical_panel" |
| certification | text | Nullable. "EPA 608 Universal", "NATE", "Master Plumber" |
| proficiency | text | "certified", "experienced", "capable", "learning" |
Table: jobs
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| crm_id | text | ID from ServiceTitan/Jobber |
| customer_name | text | "Sarah Martinez" |
| address | text | Full street address |
| lat | numeric | Geocoded latitude |
| lng | numeric | Geocoded longitude |
| job_type | text | "ac_repair", "ac_install", "water_heater", "drain_cleaning" |
| job_category | text | "diagnostic", "repair", "replacement", "maintenance" |
| estimated_value | numeric | Dollar estimate for the job |
| required_skills | text[] | Skills needed: ["ac_install", "epa_608"] |
| time_window_start | timestamptz | Earliest arrival time |
| time_window_end | timestamptz | Latest arrival time |
| status | text | "unassigned", "assigned", "in_progress", "completed" |
| assigned_tech_id | uuid | FK to techs (null until assigned) |
| priority | text | "emergency", "high", "normal", "low" |
| created_at | timestamptz | When the job entered the system |
Table: assignments
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| job_id | uuid | FK to jobs |
| tech_id | uuid | FK to techs |
| composite_score | numeric | The final weighted score (0-100) |
| distance_score | numeric | Distance component (0-100) |
| skill_score | numeric | Skill fit component (0-100) |
| revenue_score | numeric | Revenue opportunity component (0-100) |
| workload_score | numeric | Workload balance component (0-100) |
| drive_time_minutes | numeric | Estimated drive time |
| drive_distance_miles | numeric | Estimated distance |
| assigned_by | uuid | FK to dispatcher's user profile |
| assigned_at | timestamptz | When the assignment was made |
| was_recommended | boolean | Whether this tech was the top recommendation |
Table: score_weights
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| company_id | uuid | For multi-company support |
| 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 | Last 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:
| Secret | Value |
|---|---|
| DATABASE_URL | Your PostgreSQL connection string |
| GOOGLE_MAPS_API_KEY | Google Maps Platform key (Distance Matrix + Geocoding enabled) |
| SERVICETITAN_API_KEY | ServiceTitan API bearer token (or JOBBER_API_KEY) |
| SERVICETITAN_APP_KEY | ServiceTitan 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:
| Tech | Location | Skills | Close Rate | Jobs Today |
|---|---|---|---|---|
| Marcus (Senior) | 3 mi from job | ac_install (certified) | 0.65 | 1 |
| Danny (Tech) | 12 mi from job | ac_install (capable) | 0.35 | 3 |
| Kim (Apprentice) | 5 mi from job | ac_repair (learning) | 0.20 | 0 |
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:
| Job | Customer | Type | Value | Priority |
|---|---|---|---|---|
| J-1 | Patel residence | AC not cooling (diagnostic) | $200 | Normal |
| J-2 | Thompson home | AC replacement | $9,200 | High |
| J-3 | Rivera condo | Thermostat install | $350 | Normal |
| J-4 | Chen residence | Furnace no heat (diagnostic) | $200 | Emergency |
Available Techs:
| Tech | Role | Location | Jobs Today | Close Rate (Replacements) |
|---|---|---|---|---|
| Marcus | Senior | Shop (base) | 0 | 62% |
| Danny | Tech | Finishing job in North Dallas | 1 | 28% |
| Kim | Apprentice | Shop (base) | 0 | N/A |
The system generates recommendations:
Job J-2: AC Replacement ($9,200) -- High Priority
| Rank | Tech | Score | Distance | Skill | Revenue | Load | Drive |
|---|---|---|---|---|---|---|---|
| 1 | Marcus | 92 | 78 (18 min) | 100 (certified) | 95 (62% close) | 100 (0 jobs) | 18 min, 11 mi |
| 2 | Danny | 58 | 55 (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
| Rank | Tech | Score | Distance | Skill | Revenue | Load | Drive |
|---|---|---|---|---|---|---|---|
| 1 | Danny | 81 | 90 (8 min) | 80 (experienced) | 55 | 83 (1 job) | 8 min, 4 mi |
| 2 | Marcus | 62 | 78 (18 min) | 100 (certified) | 40 | 100 (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)
| Rank | Tech | Score | Distance | Skill | Revenue | Load | Drive |
|---|---|---|---|---|---|---|---|
| 1 | Kim | 76 | 85 (12 min) | 60 (capable) | 90 (low-value, save closers) | 100 (0 jobs) | 12 min, 7 mi |
| 2 | Danny | 54 | 60 (22 min) | 80 (experienced) | 55 | 67 (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