← Back to home

Case study · 04

A wine-cellar app that reads the label you're holding.

Client Not published
Sector Consumer software
Engagement Product build · ongoing
Role Full build — API, mobile app, AI pipeline

The situation

A wine collector's problem isn't buying bottles. It's knowing, two years later, what they own, where each bottle physically sits, and which one should be opened tonight. The apps that exist are either social networks with a cellar feature bolted on, or spreadsheets with a nicer font.

We were engaged to build the app around the cellar itself. Users model their actual storage — cellars made of columns, shelves, and stacks, each with its own grid of compartments — and every bottle lives at a real address in that model. Add a bottle, move it, drink it: the app tracks the physical reality.

Two constraints shaped everything. A cellar is usually the one room in the house with no phone signal, so the inventory has to work fully offline. And manual data entry is where collection apps go to die — if adding a bottle takes a minute of typing, the collection stops being maintained within a month.

The approach

The backend is a Laravel 12 API on PostgreSQL; the app is a single Expo / React Native codebase for iOS and Android. On the device, the entire inventory is mirrored into SQLite: browsing, search, filtering, and sorting run locally against that mirror, so the app is fully usable standing in a concrete basement. The mirror is rebuilt by full refresh rather than delta sync — simpler, and impossible to drift.

Data entry is where the engineering went. Point the camera at a label and the pipeline takes over: the photo is compressed on-device, run through OCR, and the raw text is handed to a language model with a schema-locked prompt that returns a structured wine record — producer, name, region, grape, vintage. That record is then fuzzy-matched against the existing catalogue so the same wine scanned by two users becomes one shared entry, not two.

// From label photo to catalogued bottle.
$text  = Vision::ocr($photo);            // raw label text
$wine  = Extractor::fromText($text);   // schema-locked LLM extraction
$match = WineBase::fuzzyMatch($wine);   // dedup against the catalogue

The data model keeps shared and personal truth apart. A canonical wine record and its vintages are shared across users; each user's bottle carries its own set of override fields, so correcting a region or a rating fixes it for you without mutating anyone else's data. And every physical action — placed, moved, drunk — appends to an immutable history log; cellar statistics are derived from that log rather than stored counters, so they can always be recomputed and always agree.

The result

One codebase serves both platforms, with over-the-air updates for anything that doesn't need an app-store release.

Adding a bottle takes a photo and a confirmation tap. The inventory works in the cellar, not just above it. And because consumption is logged with who drank what and when, the app quietly builds the collector's drinking history — the part collectors actually show their friends.

Stack

Backend
Laravel 12 · PHP 8.4
App
Expo / React Native · TypeScript
Data
PostgreSQL · SQLite on device
AI pipeline
OCR · LLM extraction · fuzzy dedup
Auth
OAuth2 · Apple & Google sign-in
Observability
Sentry