Selected work

02 Case study Beta

Ludi

Book a five-a-side pitch in a few taps, fill the team with an open match, and give venue owners a calendar instead of a WhatsApp thread. A player app, an owner dashboard in Greek and a lobby TV, all on one Postgres database.

Role
Solo
Timeline
Apr – Oct 2026
Stack
Expo / React Native · Next.js · Supabase (Postgres, PostGIS, RLS) · Stripe Connect
Status
In beta · iOS approved, release pending · payments in test mode
A worn leather football on a dark desk beside a referee’s whistle and a booking card with one slot stamped in cobalt.

01 Problem

Five-a-side football in Cyprus runs on WhatsApp. Players chase a pitch, a time and enough people through group chats and Facebook events. Venue owners take bookings by phone and message, keep the calendar in their heads, and watch mid-week slots go empty.

Ludi ("games" in Latin) is a two-sided marketplace for that: an app where players find a pitch, book and pay, and fill the team, and a dashboard where owners run their week. It started in Larnaca, with five-a-side, in English and Greek from day one.

02 What I built

  • The player app (iOS and Android, in beta): discover pitches (on a map on iOS), book and pay, share a fixture with an invite link that also works for friends without the app, open a game to the community board, and keep a record and player card with an Apple Wallet pass.
  • The owner dashboard, in Greek or English and built for a phone: a week grid and printable day sheet, walk-ins logged from a free cell, standing bookings, earnings and payouts, venue enrolment, and staff access.
  • The lobby TV: a public screen at a private link showing today's kick-offs and pitches in use, never with a player's name, and a QR code that opens the same screen on a phone.
  • The money: card payments and venue payouts through Stripe Connect, built end to end and running in test mode until payments are switched on.
426pgTAP database tests
59Database migrations
32Written ADRs
1 / 100Concurrent bookings that win

03 Architecture

A "fat database". Postgres does most of the work: availability, permissions, and the rule that a pitch can't be booked twice. Small edge functions handle the parts that move money or must happen atomically. Three surfaces sit on top, one per side of the market.

The player app, the owner dashboard and the lobby TV all sit on Supabase: Postgres with PostGIS and row-level security, plus edge functions for bookings, cancellations, venue onboarding and wallet passes. Edge functions talk to Stripe Connect, which sends payouts to venues and webhooks back into Postgres. SURFACES Player app Expo · iOS + Android book · invite · community Owner dashboard Next.js · Greek / English week grid · walk-ins Lobby TV today's kick-offs QR to the app SUPABASE Postgres + PostGIS row-level security on every table EXCLUDE USING gist (pitch, time range) → no double booking availability RPCs · pg_cron jobs auth: phone OTP · Apple · Google 14 edge functions create / cancel booking · invites venue onboarding · wallet pass MONEY Stripe Connect card payments payouts to venues webhook Monorepo (pnpm + Turborepo) · CI on GitHub Actions · hosted in Frankfurt
Fig. 1 · Three surfaces on one database

04 Key decisions

The invariant

Double-booking is impossible, not unlikely

Context
Two players tapping "book" on the same slot at the same moment is the one failure a booking app can't have.
Decision
Enforce it in Postgres with an exclusion constraint over pitch and time range, not in application code. A load test fires 100 simultaneous bookings at one slot: exactly one wins.
Trade-off
Every booking path must handle the database's refusal gracefully. That's far cheaper than a refund conversation.

ADR-0003

A fat database with row-level security

Context
One person building three surfaces can't afford a hand-written API layer for every read.
Decision
Let row-level security cover about 80% of operations directly, and keep edge functions for anything that moves money or must be atomic.
Trade-off
RLS has sharp edges, and one bit (see below). It's covered by 426 pgTAP tests.

ADR-0028

An open match is just a booking

Context
Players wanted to post "we need three more" and let strangers join.
Decision
No new tables: an open match is the booking row with one flag, surfaced through a view. Turning it off is a flag flip.
Trade-off
Match-only data such as results has nowhere to live yet. That's deliberate until results are built.

ADR-0033

Standing bookings without a scheduling engine

Context
Academies book the same pitch every week, and owners needed that on their calendar.
Decision
Materialise each standing booking as ordinary walk-in rows eight weeks ahead. The calendar, the invariant and the TV just work, and skipping a week is cancelling one row.
Trade-off
A rolling horizon to regenerate, instead of a recurrence engine everything would have to understand.

05 Hard problems

A refund that would have cost money

Two Stripe refund flags were misread. As shipped, the platform would have funded refunds while the venue kept its payout. The first fix, made from the documentation, swung it the other way and put the whole refund on the venue. A test-mode script that walks the money through every account caught that before a single real refund, and now every claim about money gets a run like it.

A view that ignored the rules

A reporting view ran with its owner's permissions, so row-level security didn't apply and any signed-in user could read daily revenue. The fix was one setting. The lesson became a pgTAP regression test, and a rule: test the claim "views inherit RLS" instead of trusting it.

A pre-launch audit

Before launch, a full audit pass found an anonymous key that could touch an internal sequence and trigger cleanup jobs, magic links that always failed, and paid extras pre-selected at checkout, which EU consumer law forbids. All were fixed before real money. The audit itself became the launch gate.

06 Outcome & what I'd do differently

The site went live at the end of August, and the apps are in beta: iOS on TestFlight, approved by Apple with the public release pending, and Android as a direct download. Two partner venues are enrolled on the owner dashboard. Payments are fully built and run in test mode until they are switched on. The repo is private, and I'm happy to do a walkthrough.

  • Test the claims, not just the code. "Views inherit RLS" and a payment library's flag semantics were both trusted until they were tested.
  • Migrations meet production data CI never sees. Constraint lists are now derived from the live definition.
  • Close the gaps the audit named. The next step is a mobile test runner and an end-to-end payment test.