All projects

Case study · Cabify Spain · Apr to Sep 2026

COPs Control

Every Tuesday the COPs Control review goes through the deviations of Spain's operational processes. Each owner used to open their own source, copy the numbers, and someone pasted them into a document the day before. The data arrived late, degraded on the way and had no history. I inverted the flow: the data extracts itself, keeps its history and goes to find the person.

561 KPIs · 33 processeslocal automation
PythonPythonPostgreSQLPostgreSQLTableauTableauGoogle Apps ScriptGoogle Apps ScriptGoogle DriveGoogle DriveMac mini (macOS)Mac mini (macOS)SlackSlackGitGit
My roleDesigned and built both versions: data model, pipeline, method, web app and alerts.
Whenv1 Apr to Jul 2026 · v2 Sep 2026
Built withPython, PostgreSQL, Tableau REST API, Apps Script, Google Drive for desktop, launchd on the Mac mini, Slack, Git, n8n for v1
StatusOn hold since 23 Sep, waiting for warehouse access to add the processes Tableau cannot serve completely.
561
KPIs from 33 processes, extracted every day with nobody involved
76
processes in the official census, each with its integration state
5
states per KPI, each with the reason written out
103
automated tests on the pipeline
The local AI agent mascotThe daily run and the Monday alert are two scheduled tasks on the team's Mac mini, the same machine as my local AI agent.
COPs Control · todaysynthetic data
COPs Control today view with the state bar and KPI cards
The "today" view. The review's own rule shaped the design: no green, the meeting is only for deviations. Green here does not mean fine; it means there is nothing to look at.

How it works

SOURCESBRIDGELOADSTORESERVETableau REST APIone call per viewAQM dashboardread through its owndata functionADP portaldaily review filesSupplier sheetread onlyApps Script bridge05:00 · three JSON filesto DriveMac mini · daily run06:00 · extract, store,model, rebuildPostgreSQLevery reading with itsextraction dateTeam web appApps Script serves thelatest JSONSlackMonday 12:00, onemessage per owner
Nobody publishes anything by hand. The Mac mini rebuilds the data at 06:00 and the web reads the latest file, about 165 KB compressed, on every visit.
  • 05:00. An Apps Script bridge reads AQM and ADP from their dashboards and a supplier sheet, and leaves three JSON files in a Drive folder that the Mac mini also sees.
  • 06:00. The daily run extracts Tableau, reads the bridge files, stores every reading in PostgreSQL with its extraction date, computes range, forecast and state, and rebuilds the web data. If one source fails, only its KPIs are marked; the rest of the run continues.
  • Every visit. The team web app, served by Apps Script, reads the latest data file. A link with the owner's name opens only their processes.
  • Monday 12:00. One Slack message per owner with their level-1 KPIs off track or below target, at most eight, worst first.

How each KPI is judged

First the realistic range: what is normal for that KPI according to its recent weeks, the median plus or minus 1.5 times its typical spread. August and Christmas are excluded, and a KPI needs at least four clean weeks before it is judged at all. Then the week's value is compared with that range and with the owner's target, if there is one.

88%89%90%91%92%93%94%95%target 93%forecast next week: 91.3%W28: 92.1%W29: 92.8%W30: 91.9%W31: 93.0%W32: 92.6%W33: 92.2% · off trackW34: 93.1%W35: 92.7%W36: 92.4%W37: 90.2% · off trackW38: 89.6% · off trackrealistic rangeforecastW28W30W32W34W36W38
Illustrative KPI with synthetic values. The band is the realistic range from the previous four weeks, the dotted line the owner's target and the hollow point next week's forecast. The last two weeks fall below the range on the side that hurts, so they are off track.
StateWhenWhat to do
Off TrackOutside the realistic range on the side that hurtsDiscuss it: something is happening that history does not explain
AverageInside the range but short of the owner's targetNormal for us, but below where we want to be
On TrackInside the range and meeting the target, or no targetNothing to look at
HighOutside the range on the good sideUnderstand what went well, in case it can be repeated
Not evaluatedFewer than 4 clean weeks of history, or an atypical weekNothing can be said yet; calling it green would be a lie

The bad side depends on the KPI: for a completion rate it is going down, for a cancellation rate it is going up, and the change is coloured by what it means, not by its direction. States are computed for every week of the series, so going back in time shows the state the KPI had then.

What a target does

The range is computed by the system ("if nothing changes, this is normal"); the target is set by a person ("this is where we want to be"). Owners set their own targets in the web app, with save and delete next to the field, and they apply in about ten minutes. A target makes the Average state possible, is drawn on the chart as a dotted line and lets a new KPI be evaluated before it has four weeks of history. Only the KPI's owner and two admins can set one; the web app checks it on save and the Mac mini checks again when applying it.

COPs Control · KPIssynthetic data
KPI cards with sparklines, forecast and realistic range
KPI cards: value, change, realistic range and next week's forecast.
COPs Control · detailsynthetic data
KPI detail with axes and hover values
Detail: axes with values and dates, and on hover the actual, forecast, range and state of that week.
COPs Control · processessynthetic data
Processes list with the share of KPIs in each state
Processes in production, each with its share of KPIs per state and a link to its raw data.
COPs Control · censussynthetic data
Census of all processes
The census: the 76 processes of the team's official catalogue and the integration state of each.
COPs Control · extractionsynthetic data
Extraction tab with schedules and sources
Extraction: every schedule and source documented, so "it updates itself" can be checked.
COPs Control · pendingsynthetic data
Pending tab with blocked processes
Pending: what is missing, and why, process by process.
COPs Control · mobilesynthetic data
COPs Control on a phone with filters and a KPI card
On a phone: filters on top, then one card per KPI.

The Monday message links straight to the board, so it has to work on a phone too: the filters stack into one column and each KPI keeps its chart, its range and the reason for its state.

# cops-control · test loop
CC
COPs ControlAPPMon 12:00
Your level-1 KPIs to discuss this week (W38) · Hub experiences
Waiting time at pickup · 6.8 min · off track, above the realistic range for 2 weeks
Completion rate · 91.2% · inside the range but below your target of 93%
14 more KPIs with nothing to look at
Open your processes: …/exec?resp=hubs
The Monday message, with synthetic values. For now it goes only to a test channel: alerts open to the team once there are 8 to 10 weeks of history to calibrate the ranges.

Version 1: reading screenshots

With no API access at first, I built an n8n workflow that signed in to Tableau, downloaded each view as an image, asked a vision model to return the numbers as strict JSON and wrote them to a weekly sheet, with tiered Slack alerts. It covered 18 processes and premiered in the July review. It worked, but image extraction occasionally invented a digit, and there was no cheap way to catch it before the meeting. That was the reason for version 2.

n8n · COPs Control v1 · 92 nodes
n8n workflow that extracts KPIs from dashboard images
Version 1 in n8n: ten dashboards in parallel, each rendered, read by a vision model, flattened and written to the weekly sheet.

Why it is on hold

The bottleneck is Tableau. Each workbook serves a different picture and almost none serves the complete KPI. I tested every technical path:

PathResultWhy
REST API, view datapartialWorks, but returns only what the sheet shows: usually the last week, sometimes one row
URL filters on the viewpartialOnly on categorical filters; on a relative date they return an empty file
Temporary copy of the workbook with weeks as columnspartialBrought 12 weeks of history for two workbooks, then deleted. Only works where the workbook can be downloaded
Metadata APIworksFinds sheets and owners; does not read data
Querying the data source directlyblockedAccess denied on every data source tried
Reusing the browser sessionblockedRejects the API tokens
Subscriptions, connectors, AI on screenshotsblockedTried by the team: does not scale and cannot guarantee complete data
Decision: do not force partial integrations. A half-complete KPI on the board looks like a complete one, and that is worse than not having it. Tableau sits on top of the warehouse, so connecting the board to the warehouse directly makes the problem disappear. Everything already built is reused: the database, the forecast, the states, the web app, the alerts and the bridge. Only the extraction layer changes.

With warehouse access

  • All 56 active processes of the census on one board, with full history from day one instead of in ten weeks.
  • One KPI, one versioned SQL query in the repository, so nobody argues about where a number comes from.
  • Alerts out of the test loop, with ranges calibrated on months of history rather than seven points.

What I learned

  • A census before a dashboard. Listing all 76 processes surfaced some the catalogue called active that no source served; they became decisions for their owners instead of silent gaps.
  • Knowing when to stop is part of the work. Writing down why each path failed made the case for warehouse access in one page.

Screenshots show the real board rendered with synthetic values, generic process names and roles instead of people.