Automated anomaly triage for 100+ specialists' timesheets
PMs were rubber-stamping hundreds of timesheet lines a month. And the wrong ones (weekend hours, days over eight, time booked on approved leave) were exactly the ones that slipped through, because nothing surfaced them and the tool timed out before the data even loaded.
How it fits together
Most requests never touch Zoho. A warm cache serves reads; only writes go through a server-side token.
- Nightly warm-up keeps the cache fresh
- Three-client OAuth round-robin spreads Zoho rate limits
The problem
The consultancy bills clients from time specialists log in Zoho Projects, and PMs must approve it monthly before invoicing. The native approval screen is a flat, unranked list with no signal about which entries are suspicious. So PMs either approved blindly or spent hours eyeballing calendars across dozens of projects. And real exposure is cross-project: a specialist can blow past a sane daily total only because of hours on another PM’s project, which a single-scope review never shows.
Diagnosis
Two problems. Approval is a signal-detection task, not a list-scrolling task. Anomalies need to be ranked and explained, and the clean majority cleared in one action. And calling Zoho directly per project and user doesn’t scale: a cold session for a large portfolio made on the order of 272 Zoho API calls, hammering Zoho’s rate limits and leaving the widget slow. And a rate-limited call (Zoho error 6045) can surface as a failure mid-load. The stakes are billing: if a load quietly comes back short, invoiceable hours go un-reviewed.
What I built
- Built a weighted anomaly-scoring engine: each specialist’s days are scored by time logged on declared leave (5.0 points/hour), weekend work (3.0), daily overtime beyond 8h (2.0), and weekly volume beyond 50h (1.5), summed into a score and bucketed into severity bands at 5 / 20 / 50 (none / low / medium / high). Each flag carrying a plain-language ‘why’.
- Reframed the UI as triage: flagged specialists appear one at a time in a severity-ordered carousel, while everyone clean collapses into a single bulk-approve action.
- Folded a specialist’s out-of-scope hours into their totals with a background cross-project scan, so portfolio-wide overtime surfaces even though a PM can only approve their own projects.
- Built a GCS cache-through backend (Flask on Cloud Run) with per-resource TTLs. Profile photos 24h, project list and PM-scope 6h, timelog reports and specialist detail 4h. So most requests never touch Zoho.
- Warmed the cache nightly for the right month (previous month before the 15th, current month from the 15th), so a PM lands on a warm cache instead of triggering the fetches.
- Chunked project reads (30 IDs per cached request), ran fetches through a 5-wide thread pool, and pushed approvals in batches of 50 through a server-side OAuth token. The widget’s ambient session can’t authorize writes.
The hard parts
- Cut Zoho API traffic ~98%. A cold session for a large portfolio dropped from ~272 calls to ~5 with the cache warm. Via a cache-through over Google Cloud Storage, using GCS’s strong read-after-write consistency so no separate lock was needed.
- Spread Zoho’s rate limits across a three-client OAuth round-robin, parking any client that hits a limit (Zoho error 6045) for an hour and refreshing tokens 30s before expiry.
- Routed every approval through the backend’s server-side OAuth token, because the widget’s ambient session is rejected on writes. A non-obvious correctness requirement that isn’t visible until writes start failing.
Results
Client and internal identifiers changed to preserve confidentiality.