Every booking an owner types into this app is now a real conversion event feeding the ads engine. Which means the moment the data is wrong — or gone — the optimization downstream is optimizing against garbage.
This piece and “When the person entering bookings is 70 years old” cover the same project from different angles. That one is about the UX decision that made daily logging plausible at all. This one is about everything that had to be true underneath that UX before the data it produced could be trusted: backups, deploy safety, test isolation, and the integrity rules that stop one mistyped booking from corrupting the calendar everyone shares.
The data went from “nice to have” to “load-bearing” overnight
For most of this tracker’s life, a lost booking record was an annoyance. The owner could reconstruct it from LINE messages or a paper notebook. But once these records became the conversion signal feeding the ads pipeline — once “this booking happened” was the thing the campaign optimizes toward — a lost or wrong record stopped being an annoyance and became a silent corruption of every downstream decision.
That reframing is what drove a cluster of work that, on the surface, looks like unglamorous plumbing. None of it adds a feature the owner can see. All of it is about the difference between data you can bet an ad budget on and data you can’t.
Two backup tiers, because one isn’t a backup
The first thing I added was a daily backup workflow: a scheduled GitHub Action that pulls the four core tables — bookings, properties, operators, operator_members — straight out of the production Supabase REST API and uploads them as a 90-day artifact.
Ninety-day artifacts are fine for “someone fat-fingered a delete yesterday.” They are useless for “what did the data look like in March.” Artifacts expire. So on the first of each month, the same workflow commits a snapshot to a dedicated backups branch — orphan-initialized if it doesn’t exist yet — giving permanent, diffable monthly checkpoints that live in git forever.
The split matters because the two failure modes are different. Daily artifacts protect against recent operator error. Monthly git snapshots protect against slow corruption — a bug that’s been quietly mangling records for weeks, the kind you only notice when a number looks wrong and you need to know when it started. One tier covers “undo last night.” The other covers “when did this go wrong.” Neither substitutes for the other.
The operator_members table got added to the backup list in a follow-up — easy to forget a table that’s mostly empty until the day you need to restore who had access to what.
A deploy script that refuses to be brave
The production deploy path is now a five-step skill that backs up before it touches anything, runs migrations, deploys code, and verifies — and stops dead at the first failed step with an explanation. No step proceeds until the previous one is confirmed clean.
The interesting failure mode here wasn’t deploy itself — it was the migration step. The original version scanned the entire supabase/ directory for .sql files and ran them all. That directory also contains schema.sql and a dev-setup script — full table definitions meant for bootstrapping an empty database, not for running against production with live data in it. Pointing a blind “run all SQL” loop at that directory is how you accidentally re-run a schema setup against a populated production table.
The fix was to read migrations from supabase/migrations/ only — a directory that contains exactly the incremental, idempotent changes, and nothing that recreates structure. It’s a one-line change in intent and a meaningful one in blast radius: the deploy tool now physically cannot reach the destructive setup scripts.
Dev kept writing to the real calendar
Every booking creates an event in the owner’s shared Google Calendar — that’s how a non-technical owner actually sees their bookings, in the app they already check on their phone. Which created a problem the moment I stood up a separate dev environment: local test bookings were writing test events into the production family calendar.
I solved this in two layers. First, a SKIP_GOOGLE_CALENDAR flag, set only in the untracked local env file, that short-circuits the calendar writes across all three booking endpoints — create, edit, delete. Production never sets it, so its behavior is untouched.
Then I refined it. Skipping calendar writes entirely meant I couldn’t actually test the calendar code path in dev. So I added two more overrides: one that points dev at a separate named test calendar, and one that forces dev events to a graphite-grey color. Now dev exercises the real Google Calendar API end to end, against an isolated calendar, with every test event visually marked as not-real. That’s the difference between “calendar code is disabled in dev” and “calendar code is fully tested in dev without ever touching production” — and the second is the one you want.
The integrity rule the database couldn’t express
The business rule is simple: a whole-property booking (包棟) can’t overlap another whole-property booking for the same property. You can’t sell the entire place twice for the same nights.
This lives in application code on both the create and edit paths, with an overlap query — checkin < newCheckout AND checkout > newCheckin — and a 409 with the conflicting dates spelled out in the error message. The edit path excludes the booking being edited from its own conflict check, which is the obvious bug you’d hit if you didn’t.
What made this more than a simple guard: the edit form now lets an owner move a booking to a different property — to fix the common mistake of logging a booking against the wrong room. That means the conflict check on edit has to run against the target property, not the booking’s current one. A booking that’s conflict-free where it is might collide the moment you move it.
Alongside that, I stopped swallowing API errors in the UI. The old failure path showed a fixed “save failed, try again” string regardless of what actually happened. Now the frontend reads body.message ?? body.error and surfaces the real reason — so a 409 conflict tells the owner exactly which existing booking is in the way, with dates, instead of a generic shrug.
Why this category of client gets this at all
None of this is hard engineering. It’s a backup workflow, a guarded deploy script, an env flag, an overlap query. What’s notable is the economics: a single B&B operator would never have justified a contractor building two-tier backups, a deploy verification harness, and a dev-environment isolation strategy for a booking app. The hours alone would dwarf the value.
This exists because the same hardening is written once and applies across every property in the stack, and because AI compresses the cost of the plumbing to the point where doing it properly is no longer a luxury reserved for clients with real budgets. The owner sees none of it. But the ad budget being spent against this data is only as trustworthy as the layer nobody sees.
Whether the whole loop holds still depends on one human habit: the owner logging every booking, every day. The plumbing can’t fix that — it can only make sure that when the habit holds, the data deserves the trust.