← Back to Work

Four supermarkets, one screen, no merchant portal.

RoleSolo designer & dev
TypeLive PWA
PlatformMobile-first
StatusIn development
Dill app showing deal cards with KES pricing from Carrefour and Naivas

The deals exist. Getting to them takes more effort than the savings are worth.

Kenya's grocery market serves 54 million people across four dominant supermarket chains — Carrefour, Naivas, QuickMart, and Greenspoon. Each runs weekly promotions. The information is real and current. It's also scattered across four separate websites, WhatsApp forwards, and paper flyers pinned to store entrances. I kept running into the same experience: I knew deals existed but checking four places wasn't worth the ten minutes it took. So I stopped checking, like most people do.

The bet: if I can make comparing deals take five seconds instead of ten minutes, more people will actually use it — and the app becomes valuable enough to sustain itself.

I cut the merchant out of the pipeline.

The standard approach is obvious: build a portal, ask each supermarket's marketing team to upload their promotions weekly. I thought about this for a while and decided against it. A marketing manager at Carrefour Kenya is not going to maintain a dashboard for an app with zero users. The portal would launch empty, and an empty deal aggregator has nothing to show anyone.

Instead, I built an automated pipeline that pulls deal data directly from retailer websites. No partnership required. No waiting for anyone to say yes. The data flows on a schedule, gets normalised into a consistent format, and lands in a Firestore database that the PWA reads from.

🌐Retailer SitesCarrefour · Naivas · QuickMart · Greenspoon
⚙️GitHub ActionsAutomated weekly scrape
🔄NormalizeClean, dedupe, structure
🗄️FirestoreSingle source of truth
📱PWA ClientOffline-first, cached

The actual data pipeline — retailer sites are scraped weekly via GitHub Actions, normalised, and served through Firestore.

Raw scrape (Carrefour)

"Ariel Power Gel 2L
Was KES 1,299 Now KES 899
Valid: 15-21 Apr
Category: HOME CARE"

Normalised output

{ "name": "Ariel Power Gel",
  "size": "2L",
  "price": 899,
  "was": 1299,
  "pct": 31,
  "retailer": "carrefour",
  "category": "household",
  "expires": "2026-04-21" }

Each retailer has different formatting, units, and date conventions. The normalisation layer standardises everything into a consistent schema the PWA can render without per-retailer logic.

Every design decision traces back to one of these.

Expensive data

Prepaid mobile data in Kenya costs real money. Every unnecessary kilobyte is a cost I'm passing to my users.

Mid-range Android

The target device is a KES 15,000 phone (~$115 USD), not a developer's MacBook. JS-heavy interfaces stutter on real hardware.

Nobody will pay for this

A person hunting for savings won't buy a subscription to find them. The product has to be free.

Zero infrastructure budget

Everything runs on free-tier GitHub Actions. This forced a fetch-once architecture that turned out leaner than anything a budget would have produced.

One hierarchy call that defines the whole product.

The deal card is the atomic unit of Dill. Every other screen exists to surface it or filter toward it. The key question was which number goes biggest: 40% off (feels exciting) or KES 200 saved (is that even worth the trip?). I chose to show both at equal weight. A percentage alone optimises for dopamine; an absolute alone favours high-ticket items. Showing both lets the shopper apply their own judgment.

Everything else on the card — brand imagery, marketing language, promo badges — got stripped out. Price is the product. The card shows four things: current price, original price, percentage saved, and which store has it. That's the information a shopper needs to decide, and nothing more.

Dill interface showing deal cards with price and percentage savings

The deal card strips everything to price, percentage, retailer, and distance. Each card is a complete decision unit.

Where Dill is now.

Dill is a live product in active development. The automated pipeline runs weekly, pulling current deals from all four retailers. The PWA serves cached data for offline use and loads under 2 seconds on a 3G connection.

What's been built

Automated scraping pipeline pulling live deal data from 4 retailers via GitHub Actions — zero ongoing cost

Offline-first PWA with service worker caching — users check deals at home on WiFi, browse cached data in-store on 3G

Full search, filtering by category and retailer, and distance-based sorting using device geolocation

Runs entirely on free-tier infrastructure — Firestore, GitHub Actions, Vercel hosting

Next steps: Validate the core assumption — that shoppers check deals before leaving home and rely on cached data at the store. This assumption shaped the entire offline architecture. Whether it reflects real behaviour, or whether people want live data and would rather wait for a connection, is the open question that determines the next iteration.

Deal cards use sufficient contrast for price text against card backgrounds, meeting WCAG 2.1 AA requirements. All interactive elements have minimum 44×44px touch targets for reliable use on mid-range Android devices. The app respects prefers-reduced-motion for users who have disabled animations at the OS level, and all navigation is keyboard-accessible.

What I learned

Building Dill forced a specific discipline: every feature had to justify its existence against a constraint. The offline-first architecture wasn't a technical preference — it was the only option for users who can't afford to stream data in a supermarket aisle. Removing the merchant portal wasn't a shortcut — it was a strategic decision that eliminated a cold-start dependency.

The biggest lesson was that removing things is harder than adding them. Stripping the deal card down to four pieces of information required fighting the instinct to add "just one more" data point. The restraint came from watching real shoppers — they don't read cards, they scan them. Every element that slows a scan costs attention that doesn't come back.