DEV Community

hao li
hao li

Posted on

I Built a Bilingual "Mini-Uber" for My Dad's Escalade Business

I Built a Bilingual "Mini-Uber" for My Dad's Escalade Business

My dad drives a Cadillac Escalade. He's licensed in California (TCP permit — the real commercial charter-party license, not the "I downloaded an app" kind), and he has a circle of regulars who call or text him when they need a ride: airport runs, business dinners, family trips to Disneyland.

For years his dispatch system was WeChat messages and memory. It worked, but it didn't scale past about ten regulars. Double bookings happened. He'd forget who wanted what. So I built him a private ride-hailing app — a tiny Uber for his own circle of drivers and riders. Tonight I finished the biggest upgrade yet: full Chinese/English bilingual support, a real account system, and four features I stole from studying Uber's playbook.

Here's the story and the architecture.

Why not just use Uber?

This is the first question everyone asks. The answer is simple: my dad isn't an Uber driver anymore. He runs his own TCP-licensed car service. Uber takes 25-30% of every fare; when the customer is your regular who found you, that cut is just a tax on a relationship you built yourself.

But "just text me" doesn't scale either. What he needed was the dispatch part of Uber — request, accept, track status, done — without the marketplace, the surge pricing, or the 30% cut. A private tool for a private circle: family drivers, a few driver friends, and their regulars. Maybe 50 people total, not 50,000.

That constraint changed every technical decision I made.

The stack: boring on purpose

  • Backend: FastAPI + SQLAlchemy, SQLite locally, Postgres (Neon) in production. One shared API serves everything.
  • Web client: plain HTML/CSS/JS, no build step, served straight from the backend at /web/. A regular opens it in their phone browser — no install, no App Store. This turned out to be the most-used client by far.
  • Mobile: Expo (React Native), one codebase for iOS + Android, mainly for the driver side (push notifications matter there).
  • Infra: a $0 server — systemd services on a small VM, Cloudflare Tunnel for HTTPS (ride.haocoach.com), no new domain purchased. Expo Push for notifications (free). Twilio Verify for SMS OTP, Resend for email OTP.
  • Auth: phone or email + one-time code. No passwords to forget. Tokens last 30 days. Roles: passenger, driver, admin. Drivers join via invite code.

The ride state machine is the heart of it:

requested → accepted → en_route → arrived → in_progress → completed
                                                  ↘ cancelled
Enter fullscreen mode Exit fullscreen mode

Only the accepting driver can advance their own ride's status. Passengers can only cancel while it's still unclaimed. Accept uses a DB-level guard against two drivers grabbing the same ride. Nothing fancy — but it's the kind of boring correctness that matters when real money and real people are involved.

Tonight's work, part 1: going bilingual

The app was Chinese-only. That was fine when every regular was a WeChat contact from the Chinese community. But my dad's business is in the San Gabriel Valley — his passengers are increasingly non-Chinese speakers, and he wants to list the business on Google Maps and Yelp. A Chinese-only booking page is a dead end for that.

I internationalized all three surfaces in one night:

Web (web/i18n.js): a dictionary + data-i18n attributes for static markup, a t() function for dynamic strings, language in localStorage, auto-detect from navigator.language. 234 keys per language, programmatically verified to be fully aligned — no missing keys allowed.

<button data-i18n="p.book">叫车</button>
Enter fullscreen mode Exit fullscreen mode
// dynamic strings go through the same dictionary
t("price.offer", { p: fmtPrice(cents) })  // "乘客出价 $25.00" / "Your offer $25.00"
Enter fullscreen mode Exit fullscreen mode

Mobile: a small i18n.js module with t(), a useI18n() hook so screens re-render on switch, persisted with AsyncStorage.

The part I'm proudest of — notifications follow the user, not the app. I added a lang column to the user table. The mobile app reports it with the push token; the web client syncs it when you flip the language toggle. Then the backend sends each person their own language:

# push.py — drivers each get their own language
by_lang = {"zh": [], "en": []}
for d in drivers:
    by_lang[_lang_of(d)].append(d.push_token)
Enter fullscreen mode Exit fullscreen mode

So a Chinese-speaking driver gets "🚕 新订单" while an English-speaking passenger gets "Driver has arrived" — in the same ride, from the same backend, with zero per-message branching in the business logic. Email templates (Resend) work the same way.

Total new strings across the whole system: a few hundred. Total new infrastructure: one column and one dictionary file per client. Bilingualism as a feature turned out to be mostly a data-modeling decision, not a framework decision.

Tonight's work, part 2: a real account system

Until tonight, "accounts" meant: you verify a code, you get a token, done. No profile, no logout that actually revoked anything, no way to leave. For a tool you're asking strangers (future Google Maps customers) to trust with their phone number, that's not enough.

Three new endpoints:

  • PATCH /me — update your own nickname and language preference. role in the request body is a hard 403; you don't get to promote yourself to driver.
  • POST /auth/logout — actually deletes the token server-side instead of just forgetting it client-side. Multi-device safe: only the current token dies.
  • DELETE /me?confirm=true — full account deletion. Tokens, saved places, share links, driver profile — gone. But ride records are anonymized, not deleted: passenger_id set to null, amounts and statuses kept. Drivers still need their books to balance, and deleting financial history because someone left would be wrong.

Plus a proper account page on the web client: profile, language toggle (synced to server), sign out, and a two-confirm delete flow. Sixteen tests, all passing. The migration for the new lang column runs automatically on startup for both SQLite and Postgres.

Tonight's work, part 3: stealing from Uber (the good parts)

I sat down and listed what Uber does that a private circle actually needs — not surge pricing or the marketplace, but the convenience features. Four made the cut:

1. Driver notes on regulars. Uber drivers can't do this, but a private driver absolutely should: "Mrs. Chen likes the AC low, pickup at the side gate, tips well." A passenger_notes table, one note per driver per passenger, surfaced automatically when the ride is accepted. This is the kind of thing only a small circle can do — institutional memory as a feature.

2. One-tap reorder. Regulars take the same trips: home → LAX, office → home. One endpoint clones a completed ride into a fresh request. The most-used feature within a week, I'd bet.

3. Hourly charter pricing. Not every job is A-to-B. "Drive us around for 4 hours" is a real product in the TCP world. So there's now a third pricing mode alongside passenger-offer and mileage: hourly, with start/stop tracked and the final fare computed from elapsed time. Uber doesn't sell this to consumers at all.

4. Flight-delay self-service. Airport pickups are maybe 40% of the business, and flights are late constantly. Passengers can now attach a flight number to the ride and push the pickup time themselves when the flight slips — no phone tag with the driver at midnight. The driver sees the updated time in their queue.

None of these are technically hard. That's the point: the moat of a small-circle tool isn't technology, it's fit. Uber can't add driver notes on regulars because Uber's model is interchangeable drivers. We can, because our model is relationships.

What "small circle" changes

Building for 50 people instead of 50,000 flips a surprising number of defaults:

  • No App Store needed. The web client (no install, opens in browser) beats the native app for passengers. TestFlight/Play internal testing is plenty for the driver side.
  • No in-app payments (yet). Passengers pay the driver directly — cash, Zelle, WeChat. Stripe integration exists for online payment, but the social default is offline. I built the payment plumbing but didn't force the behavior change.
  • Phone-call fallback is a feature, not a failure. Real-time GPS tracking was explicitly out of scope for v1. Status + a phone number covers it. When your driver is your dad's friend, "call him" is a perfectly good UX.
  • Trust is pre-existing. No identity verification theater, no rating anxiety. The rating system exists (1-5 stars + tags like "smooth driver"), but its job is feedback, not trust bootstrapping.

The whole thing runs for roughly $0/month: one small VM, Cloudflare Tunnel, Neon's free tier, Expo Push free, Twilio/Resend pay-per-use at tiny volume. The most expensive part was my time — and honestly, most of that went into getting the bilingual notification plumbing right, not the ride logic.

What's next

Tomorrow: Google Business Profile + Yelp listings so new customers can find the service, with the booking web app linked. The business verification takes a few days — the code is ready before the marketing is, for once.

The repo is MIT-licensed and public: https://github.com/hahahahahahahahah6/private-ride

If you're building tools for family businesses — the restaurant, the repair shop, the car service — I'd love to hear what worked. The "small circle" software space is weirdly underexplored: too small for SaaS companies to care about, too real to ignore. That's exactly why it's fun to build for.

Top comments (0)