Dispatch & scheduling
A network infrastructure contractor running field operations for a telecom operator · 100+ field technicians
100+ technicians, planned from a spreadsheet every morning
Over a thousand jobs waiting, copied out by hand each morning to plan the day — and no way to see which of them could never be assigned.
BeforeEvery morning, 1,100 jobs copied into a spreadsheet by hand
AfterEvery job and every technician on one screen, every 30 minutes


Before
- 1Copied out of the system by hand, every morning
- 2Which of these jobs can anyone actually be sent to?
- 3Dates stored back to front — 1 September became 9 January
After
- 1A third of the waiting jobs could never have gone to any technician — found in week one
- 2The fix is one step: give the area to a technician
Illustrative interface. All values synthetic.
- Sector
- Telecom
- Scale
- Mid-market
- Duration
- Six weeks
- Engagement
- Build sprint
In plain terms
- The problem
- A company sends engineers out to people's homes and street cabinets to fix things. Every job sits in a big computer system — but that system would only show them a screen at a time, so every morning someone copied the jobs into a spreadsheet by hand to work out who should go where.
- What was delivered
- We built a program that signs into that system by itself every half hour, takes a copy of the jobs, and keeps its own tidy list. Then it checks that list against itself, looking for things that do not add up.
- Outcome
- It found 392 jobs that no engineer could ever have been sent to, because of a setup mistake nobody had noticed. It also found every date had been stored back to front. The team now sees the real picture, and the program says so out loud when something is missing.
The detail
The problem
The field service platform held every work order, but the operation could not get at them. Dispatchers exported spreadsheets by hand and planned outside the system. Nobody had checked whether what the platform displayed matched what it exported — and it did not.
Constraints
- The vendor platform exposed no usable export API for the data the operation needed
- The platform of record could not be put at risk, so writes were out of scope
- Customer names, addresses, phone numbers, coordinates and ticket text had to stay out of any copy
- The operation runs 07:00–23:30, so collection could not be an overnight batch
Approach
- Automated the platform's own export path through a headless browser session — read-only by design, no records altered
- Excluded every customer-identifying field at the schema level rather than filtering it later
- Reconciled 3,131 exported rows down to 3,059 distinct activities across 2,951 work orders, to establish what one row actually represents
- Cross-read every technician work zone directly from the platform against the pending queue
- Verified findings by hand — 39 activity lookups across 33 work orders — before trusting any of them
What was delivered
- A back-office application with workbench, dispatch harness, planning, SLA and customer interaction
- Scheduled collection every 30 minutes across a 35-slot daily window, covering the operational day through +3 and a rolling backfill to −14
- A work-zone coverage report the dispatch team could act on directly
- A durable transaction queue with read-back verification for the one write path that was enabled
- A status model where raw platform status always stays visible and only four explicit descriptions may override it
Outcome
Pending work nobody could assign
Undetected
392 of 1,100 36%
Before: Undetected. After: 392 of 1,100.
Across 102 work zones absent from every technician's zone list — dispatchers saw "no verified options" and did not know why
Backlog the platform displayed vs. exported
383 reported
339 exported −11.5%
Before: 383 reported. After: 339 exported.
A 44-activity gap in the vendor's own UI, found on the first extraction
Date fields silently corrupted
Unnoticed
100% of dated rows
Before: Unnoticed. After: 100% of dated rows.
Day and month reversed on every record — 1 September stored as 9 January
Work-order visibility
Manual export
Every 30 minutes
Before: Manual export. After: Every 30 minutes.
Stack
Next.jsTypeScriptPostgreSQLPlaywrightBrowserbaseTrigger.devTwilio VoiceLeafletVercel
Where AI helped, and where it didn't
AI-assisted development built the collector, the 78 endpoints and the classification engine. It did not notice that 36% of the pending queue was unassignable, or that every date was reversed, or that "completed" in an export did not mean the work order was closed. Those came from knowing what the numbers were supposed to look like.
The situation
The platform of record was doing its job. The operation around it was not.
Every work order lived in the field service system, but getting a usable view meant a dispatcher signing in, applying filters by hand, exporting a spreadsheet, and rebuilding the day’s picture in Excel. Planning happened outside the system entirely.
The obvious fix — an integration — had been quoted and shelved. The vendor’s export API did not cover what was needed, and a custom integration was six figures and six months.
So the back office absorbed it. Someone signed in and exported. Someone else rebuilt the day in a spreadsheet. A third person chased the cases that looked wrong, by phone, and wrote the outcome in a column only they maintained. None of that was anyone’s actual job, and all of it had to happen before 07:00.
The cost was not the hours. It was that every number arrived through a person, so every number was a little different depending on who produced it and when — and a picture nobody can reproduce is a picture nobody can act on.
The build was the easy half
A scheduled headless browser session signs in, applies the same view a dispatcher would, exports the day, and stores the result. Read-only by design: it navigates, reads and downloads, nothing else. Customer names, addresses, phone numbers, coordinates and free-text were excluded at the schema level, so the copy could never hold them.
That sidesteps the integration project entirely and keeps the platform of record out of harm’s way.
Then the data arrived, and the interesting part started.
dispatch harness · pending / all zones
| activity | type | work zone | coverage | raw status | classified |
|---|---|---|---|---|---|
| A-4051182 | Horizontal Construction | ΚΕΝΤΡΟ — ΒΟΡΕΙΟΥ | no verified tech | 02.00.00-2 | Pending |
| A-4051204 | FTTH Support | ΠΑΡΑΛΙΑ — ΑΝΑΤΟΛΙΚΟΥ | covered | 01.01.00 | Scheduled |
| A-4051233 | Horizontal Construction | ΚΕΝΤΡΟ — ΒΟΡΕΙΟΥ | no verified tech | 02.00.00-2 | Pending |
| A-4051260 | CPE Return | ΛΟΦΟΣ — ΔΥΤΙΚΟΥ | covered | 03.00.00 | Completed override |
| A-4051291 | Onsite Visit | ΠΕΔΙΑΔΑ — ΝΟΤΙΟΥ | no verified tech | 01.00.00 | Pending |
| A-4051315 | LLU Patching | ΠΑΡΑΛΙΑ — ΑΝΑΤΟΛΙΚΟΥ | covered | 02.03.00 | Started |
Four things the data was hiding
The first export didn’t match the screen. The platform reported a non-scheduled pool of 383. The export it produced contained 339. Nobody had compared the two, because nobody had ever had both numbers at the same time.
Every date was reversed. All dated rows had day and month transposed — 1 September stored as
9 January. Raw source strings like 01/09/26 confirmed it was a parsing fault, not bad source
data. Any throughput or ageing report built on that would have been confidently wrong, and would
have looked plausible.
36% of the pending queue was unassignable. Reading every technician’s work zones directly from the platform and comparing them against pending activities: 102 zones held 392 of 1,100 pending activities and appeared in no active technician’s zone list. Dispatchers saw “no verified options” and had no way to know why. Comparing both sides while ignoring case, accents, dots and spacing produced zero near-misses — so it was a genuine configuration gap, not a name-matching artefact. The report went to the dispatch team zone by zone, with a work-zone override that records who approved it as the interim path.
“Completed” did not mean completed. Hand-verification of overdue cases found 12 of 14 still
started in the platform despite completion-sounding descriptions. And 169 Order/TT IDs mapped to
multiple work orders — so conflating the two, which the obvious data model does, silently merges
unrelated work.
What the system does with that
Finding the faults was the point of the first two weeks. The rest of the build was turning the findings into something that runs without anyone watching it.
The collector runs on a schedule and keeps its own history, so a record is not a snapshot but a sequence — which is what makes it possible to say that something changed, and when. Activities, work orders and order references are correlated rather than assumed equal, because the data proved they are not: 169 order references mapped to more than one work order, and a model that treats them as the same thing merges unrelated jobs silently.
Contact attempts are part of the same record. A call placed from the dispatch screen writes its outcome back against the case, so “second call unanswered” is a fact in the system rather than a note in someone’s head.
workbench · today's summary
3 1,395 with open work 488 visits completed today 2,774 in 7 days 6,947 work orders completed
Today's work
24
4 To process
438
Invoice review
—
All cases
9,500
- 1 Collection runs itself and states when it last ran. If it stops, that shows here — nobody finds out two days later.
- 2 Records that failed to correlate are surfaced, not hidden. 47 activities without a work-order link is a finding, not an error to bury.
- 3 The counts come from reconciling records against each other, not from counting rows by hand in a spreadsheet.
- 4 The "to process" queue is the single number that decides what the team does today.
What it runs on
Nothing exotic, and deliberately so. A scheduled job runner (Trigger.dev) drives the collection cycle. A hosted browser (Browserbase, over Playwright) performs the export the vendor API does not offer. Postgres holds the reconciled history, with hand-numbered migrations rather than an ORM’s generated ones, because the correlation logic is the product and we wanted it in plain SQL where it can be read. Twilio Voice places the calls. Leaflet draws the map. It deploys to Vercel.
The one rule applied throughout: the platform of record is never written to except through a single verified path, and everything else is read-only.
It tells you when it breaks
A system that quietly stops collecting is worse than no system, because the numbers keep looking plausible while going stale.
So the workbench states when collection last ran, and surfaces what failed to correlate instead of discarding it — the activities with no work-order link are shown as a count on the main screen, not filed in a log. Corrections made by the dispatch team are recorded against the record that prompted them, so the rules that decide coverage improve by being used rather than by being reconfigured.
Why the classification layer is deliberately timid
Given all that, the temptation is to build something clever that normalises everything into a clean status.
We did the opposite. Raw platform status always stays visible. Only four explicit completion descriptions may override it, and any difference carries a tag explaining itself. Missing fields are never read as status transitions, and an activity disappearing from a queue is never treated as proof it closed.
A layer that silently rewrites the source of record is worse than no layer at all, because people stop trusting it the first time it is wrong — and they only need to catch it once.
What actually changed
Before, the operation’s picture of itself was assembled by hand each morning and was slightly different every time. Now it is derived, reproducible, and states its own gaps.
That is the whole difference. Not that the work got faster — that the operation can see itself, and can tell you when what it is seeing is incomplete.
Client anonymised. Most of this work touches commercial or operational internals; references available on a call.