Selected work

01 Case study Private

Cinna

A live map of every bus in Cyprus, arrival estimates you can trust, and a trip planner that would rather say “I don’t know” than guess. Flutter on iOS and Android, over a Django and PostGIS backend fed by the national real-time feed.

Role
Solo
Timeline
Dec 2025 – present
Stack
Flutter · Riverpod · Django REST · PostGIS · Celery · Redis
Status
Backend in production · iOS on TestFlight
A folded transit map on a dark desk with one route traced in cobalt thread, a brass token beside it.

01 Problem

Cyprus publishes open timetables and a live vehicle-position feed for its buses, but riders still stand at stops not knowing whether the bus is two minutes away or already gone. General-purpose map apps paper over the gaps in the local data with confident-looking estimates that are sometimes simply wrong.

The brief I set myself was trust: anyone at a bus stop should be able to open the app and believe what it shows. When the data can't support an answer, the app should say so instead of guessing.

02 What I built

A Flutter app for iOS and Android over a Django and PostGIS backend:

  • A live map of every bus on a route across all seven Cypriot operators, refreshed from the national feed every 15 seconds.
  • Nearby stops, arrival estimates, and a trip planner that says why when it has no answer.
  • Ticket purchase through Stripe with Apple Wallet passes, built against Stripe's sandbox and switched off until the app is licensed to issue tickets.
7Bus operators
15 sLive-feed cycle
15Written ADRs
1,400+Automated tests

03 Architecture

The live feed and the timetable take different paths into one PostGIS database. A dedicated process ingests vehicle positions. Celery handles the slower jobs: the nightly timetable import and cache warming. The API answers spatial questions straight from Postgres.

Data flow: the national GTFS-RT vehicle feed is polled every 15 seconds by a dedicated fetcher and upserted into PostGIS; static GTFS timetables are imported nightly by Celery; a Django REST API serves spatial queries to the Flutter app, with Redis for queues and caching. SOURCES GTFS-RT feed vehicle positions 7 operators, 1 stream Static GTFS one ZIP per operator stops, routes, shapes INGEST Fetcher process poll every 15 s, decode, bounding-box filter Celery beat nightly import, 30 s cache warming STORE Postgres + PostGIS vehicles · current state, upserted vehicle_positions · append-only history stops, routes, trips, shapes · ST_DWithin Redis Celery broker + caches SERVE Django REST nearby, vehicles, trip planner Flutter app iOS + Android, Riverpod, maps Hosted on DigitalOcean App Platform (Frankfurt) · CI/CD via GitHub Actions · errors to Sentry
Fig. 1 · Two ingestion paths into one spatial store

04 A week with Cinna

What "an app you can trust at the bus stop" looks like across one commuter's week in Nicosia. The phone is the app, and the map is one route from home to the university. Every beat is in the app except Thursday's ticket, which is built and stays off until Cinna is licensed to issue tickets. The times and figures are examples, not real data.

  1. MonAt the stop

    Bus 30 is coming in live, refreshed from the national feed every 15 seconds. Bus 22 is marked as scheduled, because that’s all it is.

  2. TueThe feed goes quiet

    Bus 30 stops reporting its position. Instead of a stale countdown, Cinna falls back to the timetable and says why.

  3. WedPlan a trip

    Home to the university: the planner offers the direct bus or a walk and a change, and draws the route.

  4. ThuBoard and ride

    Ride mode shows one thing at a time: the next stop. (Wallet tickets, scanned by the driver’s app, are built and stay off until Cinna is licensed.)

  5. FriJourney complete

    The trip summary: time on board and CO₂ saved compared with driving, added to your week.

05 Try it

The live feed says where a bus is, not which trip it's running. Below, the same road is read two ways. A naive estimator picks the nearest bus, whatever direction it's heading. Cinna only trusts buses heading toward your stop, and falls back to the timetable when it can't vouch for one. Watch the confident-wrong counters.

Interactive · naive vs tiered arrival estimates

A simulation comparing a naive nearest-bus ETA with Cinna's bearing-aware, tiered estimate. (This demo needs JavaScript.)

06 Key decisions

Every significant call is written down as an architecture decision record, 15 so far. Each one includes what would make me revisit it. Four of them:

ADR-002

A dedicated fetcher, not a Celery task

Context
The live feed needs polling every 15 seconds. Celery's scheduler isn't precise below a minute, and a hung request to the upstream feed would tie up a worker that other jobs need.
Decision
Run ingestion as its own small long-lived process with a per-operator decoder, a Cyprus bounding-box filter and retries. Write current state to a narrow table and history to an append-only one.
Trade-off
One more container to run. Kafka or Redis Streams were considered and parked until the fleet reaches about 5,000 vehicles.

ADR-003

Django + GeoDjango over a lighter framework

Context
Almost every question the app asks is spatial: what's near me, which bus is on this route, how far is it from my stop.
Decision
Django REST on Postgres with PostGIS, for the ORM's spatial lookups, the migrations and a free admin. A database without spatial indexes was rejected outright.
Trade-off
A heavier framework than FastAPI, in exchange for spatial queries that are one line and indexed.

ADR-006 / 010

Zero confident-wrong arrival times

Context
The live feed doesn't say which scheduled trip a bus is serving, so a naive ETA can attach a bus to the wrong route or direction and show a confident, wrong answer.
Decision
A four-tier fallback that infers route and direction from the bus's bearing. A spike showed bearing separates 90% of trips. It sits behind a kill switch, with the explicit goal of 0% confident-wrong estimates: fall back to the timetable, or say "unknown", before guessing.
Trade-off
Fewer live estimates on screen. Showing fewer is the point.

ADR-014

Reversing an earlier decision on maps

Context
ADR-001 chose an open-source map stack. Months later, consistency with the rest of the platform mattered more than avoiding vendor lock-in.
Decision
Migrate to the Google Maps SDK, with bus markers drawn as bitmaps plus a Flutter overlay, and record it as a new ADR that supersedes the old one rather than editing history.
Trade-off
Offline map tiles were lost. Writing the reversal down is what makes the log trustworthy.

07 Hard problems

The timetable that froze in production

The production worker was started without its queue list, so the timetable-import queue was never consumed. Nobody noticed until the published calendar expired and every route went inactive. The health check had been counting rows, not services running today. The fix went beyond the flag: the health check now grades freshness, tests assert that production and local configs subscribe to the same queues, and a calendar bug found along the way was fixed too.

fork() doesn't copy threads

The trip planner returned a 500 for every production request while working fine locally. A warm-up thread started at app load was still mid-import when gunicorn forked its workers, leaving them with a half-initialised module. I reproduced it with production settings, then moved the warm-up into a post-fork hook.

Bugs that only exist at fleet scale

With hundreds of live buses, one vehicle could claim every scheduled slot on a route. "Nearest" was also ranked ahead of "soonest", and some estimates drifted far into the future. Each fix was small: rank by ETA, cap estimates at 30 minutes, one bus per slot. None of these bugs showed up with test data.

08 Outcome & what I'd do differently

The backend runs in production and the iOS app is on TestFlight; the Android app builds in CI. Payments and Apple Wallet passes are built and stay off until Cinna is licensed to issue tickets. The code is private, and I'm happy to walk through it on a call.

  • Row counts aren't freshness. Health checks should ask "is today's service running?", not "is the table non-empty?".
  • One definition per service. Production and local configs drifted because the same worker was described in two files. Now a test compares them.
  • Measure the fallback before tuning it. The bearing spike decided the ETA design. I'd run spikes like that earlier and more often.