Skip to content

Case study

Hospitality Operations

A PWA driving scheduling, inventory, and supplier integrations for a mid-volume regional restaurant.

Year
2025—
Status
live
Role
Architect & Implementer
Stack
React Native, Expo SDK 54, Supabase, TypeScript, Playwright

A ~20-person restaurant ran scheduling, ordering, and its menu by hand. This PWA puts scheduling, inventory, six-supplier ordering, and a live website in one system.

At a glance: staff, floor, the public site, NFC taps, and six suppliers all feed one shared database that drives scheduling, ordering, and live operations.
At a glance

Before → after

Weekly staff scheduling
Before~2 h of WhatsApp back-and-forth
After~30 min in the app
Menu translation
Before~€200 per menu, outsourced
Aftercents per run, in-app LLM
Seasonal menu change
Beforedays of cross-system edits
Afterone in-app action

~2h → ~30min

weekly staff scheduling

~€200 → cents

menu translation

since Dec 2025

in production

The technical breakdownExpand · ~6 min

The business problem

A mid-volume regional restaurant ran scheduling, ordering, and its public menu by hand. Weekly staff planning was about two hours of WhatsApp back-and-forth; supplier orders got forgotten; the public website lagged the real menu, so guests arrived on stale information; and handwritten reservations sometimes got lost. And no one could see how a service actually paced — how long a table waited for drinks, then food — so there was nothing concrete to improve against.

The people in the loop: around twenty staff and the owners who plan shifts and place orders, the guests who book and eat, and six B2B suppliers whose catalogues and prices change constantly — none of whom offered a usable API. The job was to put all of that in one system that everyone could trust at the same time.

The architecture

A cross-platform PWA — owner web, staff phones, a floor view — over a single managed Postgres with row-level security and realtime, plus a server-rendered public website sharing the same database.

Client surfaces — public website, staff app, floor view, and NFC table taps — all write to one shared Postgres with row-level security and realtime; a row insert triggers tiered-cache updates and email/push confirmations.
Client surfaces — public website, staff app, floor view, and NFC table taps — all write to one shared Postgres with row-level security and realtime; a row insert triggers tiered-cache updates and email/push confirmations.

Data is cached in tiers by how often it actually changes — live floor data refreshes in seconds, menus in days — behind one adapter over browser and native storage. The public site renders from the same database the kitchen edits, and both web and app react to the same row insert, so a reservation entered anywhere fires exactly one confirmation.

The supplier layer was the hardest part of the build. None of the six B2B suppliers offered a usable API. One had a webshop whose ordering endpoints I reverse-engineered into a programmatic order flow; another exposed a semi-structured JSON feed; a third had nothing but a catalogue, scraped with Playwright. Over those three completely different access methods sits a normalization pipeline that fuzzy-matches the same product across all six suppliers — so a price comparison compares like with like, and an order can route to whoever is cheapest that week. Getting heterogeneous, undocumented sources to behave like one clean catalogue was the bulk of the engineering, and it's what turns "ordering" from a chore into a decision.

Six B2B suppliers reached three different ways — a reverse-engineered ordering API, a semi-structured JSON feed, and a Playwright-scraped catalogue — feed one fuzzy product-normalization layer that reconciles the same product across vendors before it reaches the shared database.
Six B2B suppliers reached three different ways — a reverse-engineered ordering API, a semi-structured JSON feed, and a Playwright-scraped catalogue — feed one fuzzy product-normalization layer that reconciles the same product across vendors before it reaches the shared database.
Supplier sourceIntegration methodFragility
Webshop, no APIreverse-engineered ordering endpointsbreaks on a site redesign
Structured-ishsemi-structured JSON feedbreaks on schema drift
Catalogue onlyPlaywright-scrapedbreaks on markup change

One change, many destinations. This is where the integration bus pays off. A single menu edit propagates from one place: the public website updates instantly, the stored recipes that drive portion and quantity calculations update with it, and those quantities feed the reverse-engineered supplier layer that places the orders. Seasonal menu turnover — three menus a year — used to be days of manual cross-system editing; now it's one in-app action. Menu translation rides the same path: an in-app LLM renders each menu into the other languages, replacing a roughly €200-per-menu professional job with a cents-per-run call.

Live table status over NFC. Staff set a table's live state — drinks served, food served, table cleared — by tapping an NFC tag at the table. Each tap is a timestamped event written to the same realtime Postgres backbone (another row insert → notify producer), so table state is shared instantly across the web, staff, and floor views.

The database is the integration bus: the source of truth and the event are the same row.

Decisions & trade-offs

Cache TTL per data type, not one global constant. Sizing freshness by volatility killed both the staleness bugs and the needless round-trips a single TTL caused. The cost is one invariant you have to hold everywhere: every write invalidates its cache key.

One website, always in sync with the menu. The public site reads from the same database the kitchen edits, so a menu change is live immediately — no second system to update, no lag for guests to trip over. The trade-off is that the public site inherits the operational database's shape rather than a tidy content model of its own.

Reverse-engineer the suppliers, and own the fragility. Building against undocumented webshop endpoints and a scraper was the only way to get prices the suppliers simply don't publish — but those integrations break whenever a supplier redesigns their site. The trade-off was explicit: a brittle integration that delivers live price comparison beats a stable one that doesn't exist. Each source is isolated and validated independently, so when one breaks it degrades alone instead of taking the catalogue down with it.

NFC tap over in-app navigation. Live status is set by a physical tap on a tag at the table, not by digging through app screens. During a busy service, staff won't navigate menus — a friction-free tap is what actually gets used, and adoption, not the data model, is the real constraint. The trade-off: NFC tags are physical infrastructure to place and maintain, and the data is only ever as good as staff remembering to tap.

What broke

The nastiest bug was a web-only infinite spinner on resume. The browser serializes auth-token refresh through a lock, and on resume a data read waited on a lock that never returned. The first fix — a 10-second timeout — made the spinner settle instead of hang, but the next read failed identically, because the token was still dead. The real fix refreshed the session before the post-resume read storm.

Sequence of the resume spinner bug: on resume the data read waits on the browser's auth-token-refresh lock; a 10-second timeout makes the spinner settle but the next read fails because the token is still dead; the real fix refreshes the session before the post-resume read storm.
Sequence of the resume spinner bug: on resume the data read waits on the browser's auth-token-refresh lock; a 10-second timeout makes the spinner settle but the next read fails because the token is still dead; the real fix refreshes the session before the post-resume read storm.

A timeout buys you a survivable UX; only asking "what fails on the next attempt?" gets you the cause.

The outcome

In production since December 2025, with features still landing — and the wins are kept deliberately distinct, each scoped to exactly what it changed rather than summed into one number.

Scheduling dropped from ~2 hours of WhatsApp coordination to ~30 minutes a week in the app (that figure is scheduling alone). Inventory and order generation, formerly a couple of hours of the owner's week, is now handled by staff directly in the app — a delegation and capability gain, not a measured number. Seasonal menu changes (three menus a year) went from several days of manual cross-system work — website, stored recipe quantities, and the supplier links they feed — to a single in-app action. Menu translation moved from a professional job at roughly €200 per menu to an in-app LLM translation costing cents. Reservations are now captured digitally, so bookings no longer get lost, and the guest contact details that come with them opened a feedback loop that didn't exist on paper.

The NFC layer unlocks something the restaurant never had: because every status tap is timestamped, service times — time-to-drinks, time-to-food, table turnover — become measurable for the first time. No numbers are claimed yet; the capability itself is the result.

Planned, not shipped: the live "table free" signal will feed real-time reservation capacity on the public website, so online booking reflects actual floor availability. The data already flows on the bus — wiring it into the public booking flow is the next step.