The problem
AGILA tracked more than 500 cats across campus. Health status, neutering history, vaccination records, adoption eligibility: all of it spread across a shared spreadsheet, Google Forms and a Facebook page. It worked, in the sense that the org had run on it for years, but the cost landed on more than 40 volunteers: duplicate rows, competing versions of the same file, and no reliable way to answer a question about one cat. Adoptable cats were effectively unfindable by anyone outside the org. I found the problem myself, pitched it, and led a four-person team to fix it.
The constraint that decided everything
The obvious build is a database with an admin panel, then a migration weekend, then training. I did not believe that would survive. The spreadsheet was not just a storage format for these volunteers. It was the workflow they trusted, edited on phones between shelter visits, shared with people who would never log into an internal tool. A cutover would have meant asking 40 volunteers to change how they work in exchange for a promise. Most would have quietly kept using the sheet, and then we would have had two sources of truth instead of one.
The engine
So the sheet stayed, and the database became the source of truth underneath it. A Cloudflare Worker fires every twenty minutes and runs a fixed sequence: reconcile, reverse sync from sheet to database, import photos, forward sync back out, regenerate summaries. Two ordering decisions carry most of the weight. Reconciliation runs before the tick’s idle exit, because forward sync is task-driven, so nothing would otherwise notice a cat whose row had gone missing. And reverse runs before photo import, so a cat created from a new sheet row exists before the importer tries to attach its photo.
One column holds it together
Column Y of every region sheet holds a UUID that is the cat’s database primary key. Not a foreign key into a mapping table: the key itself. A row without one is invisible to sync, which makes that failure loud instead of silent. An Apps Script trigger stamps the editor and the edit time into the two columns beside it, and that timestamp is how the engine tells a human edit from its own write. Conflicts resolve last-edit-wins, with a five-second buffer favouring the database.
Two rules I would defend in a review
A blank health cell imports as unknown, never as healthy, and vaccination reads Yes, Expired or Unknown, because a shot from two years ago is not protection. On a page where strangers decide whether to take an animal home, missing data must not become a false medical claim. Second: the sync can be retired, not only paused. Retiring releases the app’s column protections and leaves AGILA a spreadsheet it can still edit, rather than one locked by software nobody maintains. A student project should be able to end without taking the org’s data with it.
The outcome
AGILA runs on it now, at ateneanagila.com, with the adoption catalog public and indexed. The records are structured and queryable, the manual reconciliation is gone, and the volunteers’ day did not change, which was the point. The engineering that mattered here was not the CRUD. It was deciding that the old system had a right to keep existing.
Screenshot
