Building PAO Hub, an App for One Club

September 6, 2026 (1mo ago)

PAO Hub is an unofficial Panathinaikos app: live scores, fixtures, tables, the squad, the club’s history and the day’s news, in Greek and English. It is built with Expo and React Native on a Supabase backend, the web build is live, and the phone apps are not in the stores yet.

That is the elevator version, and it is the least interesting thing about it. What I actually spent my time on was not the app. It was the data behind it — where it comes from, who owns it, and what happens when the place you rent it from disappears.

Why one club, and why two sports

Panathinaikos plays football and basketball at a serious level, and following both means living in two different information worlds. The Greek sports portals cover everything, which means most of what they show you is about somebody else, wrapped in more ads than content. The sources that are actually good at one sport are not the sources that are good at the other.

What I wanted was mundane: one chronological list of every fixture the club has, both sports mixed together, with a divider at today. A table that is current. A squad I can tap through. News from the Greek publishers who actually cover the club, without the interstitials. That is the entire product thesis, and it only works if you narrow it to one club — the moment you generalise, you are building a worse version of an app that already exists.

The data was the whole project

I started on a sports data API’s free tier, which advertised everything I needed: both sports, every competition Panathinaikos plays in, crests, live scores, per-quarter basketball breakdowns. The field inventory was genuinely complete.

Then I probed it properly, and found the paywall was not drawn where I assumed. It was not drawn across competitions at all. It was drawn across time:

How you askWhat the free tier answers
?season=YYYY2022 – 2024 only
?date=YYYY-MM-DDyesterday, today, tomorrow

Both sports, same limits. There was no way to ask “when does Panathinaikos next play”. The competition listings cheerfully advertised seasons through 2026 that the data endpoints then refused — coverage listings are marketing, entitlement lives in the endpoints, and only the endpoints tell you the truth.

That looks like a dead end. It is actually a design brief. If the provider will only ever show you a three-day window, then you poll that window every day and keep what you see. Two requests a day per sport, filtered to one team, written into Postgres and never deleted. The provider supplies a stream; you become the archive.

I am glad I was forced into that, because twelve days after I measured those limits the provider was suspended outright. Not rate-limited, not repriced — gone. Football moved to ESPN’s feed, basketball to EuroLeague’s own, and both migrations were a couple of days of adapter work instead of a catastrophe, because the thing that would have hurt to lose — every result the app had ever seen — was already sitting in my own database.

If the value of your product is history, do not rent history. Rent the live window and accumulate the rest yourself.

The part of this app I was most careful about has no interesting code in it at all.

An app that collects Greek publishers’ articles and shows them to Greek readers is not in a grey area — it is precisely the thing Greek Law 2121/1993, as amended by 4996/2022, wrote a press publishers’ right about. That right does not cover hyperlinks, individual words, “very short extracts”, or facts. Greece implemented “very short extract” qualitatively rather than with a word count: the question is whether what you show substitutes for the publication, or stops the reader going to it.

So, for every article, the app stores and shows the headline, the publisher’s own social-card summary, the image URL (hotlinked, never re-hosted), the date, and the canonical link. Full article bodies are stored for exactly one publisher, the one whose terms allow it. Everything else is a card that exists to send you to the publisher’s page, because that is both the legal position and the honest one.

The crawler is deliberately boring too: one request per host every 400ms, a declared user agent that says what it is, and every candidate source’s robots.txt read before it was added. One publisher’s RSS endpoint answers 403 to that user agent. I could have sent a browser string and it would have worked. I left the feed null and took that source’s public section pages instead, because the moment you disguise your bot you have moved from “a service the publisher tolerates” to “a service the publisher blocked”, and you have written down the evidence yourself.

There is one more fact that colours all of this: the app carries ads. Every use above is therefore commercial, which weakens every defence available. When the conservative reading and the convenient reading disagreed, I took the conservative one.

The same instinct settled the club history section. Wikipedia is CC BY-SA, which means its facts are free and its sentences are not — and share-alike would otherwise reach into an app that is all rights reserved. So the 115 trophies were reconciled across two sources and typed by hand, and the twelve era paragraphs were written from scratch, in both languages. That turned out better anyway: writing your own copy means you are allowed to take a side.

I left the 1971 European Cup final out of the trophy cabinet, incidentally, which is the club’s most-told story. A cabinet that lists finals lost is a cabinet nobody trusts about the ones it says were won.

Bilingual is not a translate() call

The app is Greek first and English second, switchable in settings. I did not add an i18n library; src/i18n is a typed dictionary, Record<Locale, Strings> over an interface Strings, so a key added to Greek and forgotten in English is a compile error rather than a string that silently falls back.

The interesting part was what fell out of the work. Three plural bugs had been sitting in the Greek UI since the beginning — «1 ΩΡΕΣ» in a countdown, «1 νίκες» in the standings, «1 αγώνες» in the head-to-head — all invisible to every static check I had, because they are grammar, not types. Every count goes through Intl.PluralRules now.

Worse, two display strings turned out to be doing structural work: an outcome was typed as 'Ν' | 'Ι' | 'Η' and compared with === to choose a colour, and a team’s display name was compared against 'ΠΑΟ' to decide which side of a match card got full-strength ink. Both would have broken silently the moment those strings were translated. If a comparison decides what something looks like, it should be comparing a token, never a label.

The news stays Greek in both languages, deliberately, and the English settings screen says so in one line. Every source is a Greek publisher and the sport/category classifier is Greek keyword matching — translating the feed would break routing, not just display.

The bug that passed every check I had

My favourite failure in the whole project was one line. A feed row needed “matches played on this day in history”, and it went out as:

played_on=like.*-09-05

TypeScript sees two strings being concatenated and is perfectly happy. The linter is happy. The build is green. And it cannot work, for two independent reasons: Postgres has no LIKE for a date column, and PostgREST has no cast inside a filter.

It was caught by running the query against production before a build existed to run it from. The fix was a database function using a month/day index that already existed. The lesson I keep relearning: anything that crosses a network boundary as a string — a query filter, a route, a header — is outside your type system’s jurisdiction, no matter how confident your editor looks. Run it against the real thing early.

Where it is now

The web build is live at paohub.rentzilas.dev — fixtures, tables, squad, history and the news feed, in both languages. The phone builds run on a device, with push notifications composed per language rather than in Greek only, and neither store listing exists yet; the download buttons on the site are honest about that.

The thing I would tell anyone building something similar: pick the narrowest possible audience, then spend your effort on the parts nobody sees. Nobody opening this app will ever know that the results archive is self-accumulated, that one publisher’s body text is stored and two others’ are not, or that a date filter was rewritten as a database function. They will know that the fixtures are right, that the news is not spam, and that it works in their language. All the interesting work is upstream of that, which is exactly how it should be.