The ads accounts were optimizing on signals that had nothing to do with revenue.
Each B&B operator in Hengchun runs a separate Google Ads account. Weekly reports show spend, CPC, impressions — all optimizing against contact actions Google can track: visitors tapping the phone number, the LINE button, WhatsApp. What Google cannot see is what happens next: the call placed from a different app, the LINE message sent three days after the ad view, the booking confirmed over the phone when someone’s travel plans finally solidify. Revenue happens entirely off-platform, in the owner’s head and on a paper calendar.
Without real booking data, the only conversion proxy available is on-site contact intent. That’s not useless, but it’s not a booking either. Smart Bidding trained on contact signals learns which visitors tap buttons — not which ones stay. The two groups don’t fully overlap, and over weeks the algorithm drifts toward click-prone audiences at the expense of booking-prone ones.
The booking tracker closes that loop. An owner records a confirmed booking — property, channel, booking type, dates — and the entry simultaneously becomes a Google Calendar event the whole family can see and a conversion signal in the correct Google Ads account.
The routing constraint that shaped everything
The multi-account requirement introduced the first hard technical constraint.
The obvious path for sending offline conversions to Google Ads is the uploadClickConversions API — direct, properly attributed. Except: bookings are recorded after the fact by the owner, not at the click moment. There is no gclid. Google’s endpoint requires one of gclid, wbraid, or gbraid and returns 400 for anything without it. That’s a dead end.
The alternative: GA4 Measurement Protocol with per-property event names. All operators share one GA4 property. Each operator’s Google Ads account imports its own named event from that shared property — property A fires booking_[a]_confirmed, property B fires booking_[b]_confirmed. Both Ads accounts link the same GA4 property but each reads a different event, so there’s no cross-account contamination. The GA4 Measurement Protocol endpoint doesn’t require a gclid, which solves the after-the-fact recording constraint cleanly.
The conversion value isn’t fixed. The booking payload carries a dynamic value based on booking type and night count: a whole-property per-night rate roughly six times the per-room rate, drawn from the operators’ published pricing. Smart Bidding weights conversions by value, so a three-night whole-property booking and a one-night single room should carry proportionally different signals to the algorithm.
On the backend, the booking API route resolves the operator, fetches the property’s event name from the properties table, computes value as nights × per_night_rate, and fires the GA4 Measurement Protocol request. The property table is the routing table — adding a new operator means adding a row, not touching application code.
The form had to survive a 70-year-old’s thumb
The other constraint was non-technical but equally non-negotiable: the person who enters bookings daily at one property is in her seventies.
The first version of the form followed standard mobile conventions. Small date pickers, drop-down selects, inputs that accept logically invalid ranges and then silently reject submission. Each failure mode generated a support call — at this scale, that means a call to me personally.
The redesign eliminated every source of input ambiguity. Properties, channels (LINE, phone, WhatsApp, Facebook message), and booking type are large button grids with orange selected states, no text input required. Date logic enforces its own constraints in the component: checkout minimum is always check-in plus one day, and if the owner changes the check-in date past the existing checkout, the checkout field clears rather than sitting silently invalid. The success state is full-screen — not a toast, a full takeover.
The app runs as a PWA installed to the home screen. Native iOS would require an Apple Developer account and App Store review; deploying to Vercel via GitHub takes the distribution overhead to zero, and Safari’s Add to Home Screen makes it feel identical to a native app on the lock screen.
One calendar per operator, not per user
A family-run property has multiple operators at multiple devices. The husband takes calls, the wife manages walk-ins, an adult daughter handles messaging apps from the city. Everyone needs visibility into the same booking state, and any of them might be the one recording a booking on any given day.
The intuitive model — each account writes events to its own calendar — produces three partial views of the same property with no single source of truth. Nobody trusts any of them.
The correct model: one calendar per operator, owned by the primary account holder, with other family members getting writer access through Google Calendar ACL — native Google Calendar sharing, not app-level permissions, so members can also modify events directly in Google Calendar without going through the booking app.
On first booking, the route looks for a calendar ID stored on the operator record. If none exists, it calls calendarList.list() to find a calendar named 訂房日誌, creates one if it doesn’t exist, persists the ID back to the operator record, and issues sharing invitations via acl.insert to every non-owner member. The acl.insert call returns 409 if the permission already exists — the implementation treats 409 as success and skips it — but re-triggering the invitation on every subsequent booking generates noise that gets tools muted. The invite fires once: when the calendar is first created.
One production bug that doesn’t appear in local dev: the initial implementation dispatched the calendar write as a fire-and-forget async call inside the route handler. The booking saved to Supabase, the Vercel serverless function exited, and the calendar write never completed. The owner saw a confirmation screen. The family’s shared calendar showed nothing. One awaited call fixed it — but the failure mode is invisible locally because the Node process doesn’t exit between requests.
The multi-user access layer uses Supabase with a SECURITY DEFINER SQL function to map any authenticated user ID to their operator ID. Supabase’s RLS policies can then enforce row-level isolation without exposing the mapping logic to client code.
Channel data as a secondary output
When recording a booking, the owner selects the contact channel. That field rides in the GA4 event as a custom parameter alongside the conversion value.
Over weeks it builds a channel breakdown: not which surface drives the most contact events, but which drives confirmed bookings. If LINE generates 40% of contact actions but only 20% of confirmed bookings, the LINE audience has a conversion problem worth investigating before doubling down on it in campaign spend decisions.
The economic argument
A custom offline conversion pipeline — cross-account routing, dynamic value calculation, shared calendar infrastructure, UX designed around a specific person — used to require multiple separate contractors and a budget that a family-run B&B couldn’t justify.
The reason this exists is that AI compressed the build time to where the economics work at the scale of three operators in Hengchun. The conversion column is no longer empty. The algorithm is no longer optimizing for button-tappers.