Here's a failure mode I was determined to avoid: the season page says a player shoots 62%, the live view says 58%, and the native app says 60% — all for the same player, same games. Three numbers, three slightly different implementations, zero trust.
This is post #3 in my build-in-public series about SportsFlow. The fix was to make the analytics a single thing that runs in all three places.
The shape: a pure-compute package
There's a package, @sportsflow/analytics, with one hard rule: no database dependencies, no DOM dependencies. It's just functions. Events in, numbers out.
match_event rows ──adapter──▶ AnalyticsEvent[] ──compute──▶ KPIs / heatmaps / momentum
Enter fullscreen mode Exit fullscreen mode
Because it's pure, the same functions run in:
- The web app — over data fetched via tRPC.
- The native app (Expo) — same import, same results.
- Offline, during live tracking — over the in-memory event queue, before anything has even synced to the server.
That third one is the payoff. The live "stats" tab during a game isn't a separate, simpler implementation that you hope matches the real one later. It is the real one, fed from local events. When the game ends and the data syncs, the season numbers are computed by the same code. No drift, by construction.
Decoupling from the database
The trick that makes this work is the AnalyticsEvent type — a DB-decoupled view of an event with all the metadata it needs resolved inline: which side it's for, whether it scores, the action type, whether the player is a goalkeeper. The compute functions never reach back into the database to look anything up. An adapter turns raw match_event rows (and custom-action definitions) into AnalyticsEvents, and from there it's all pure transformation.
This is the boundary that keeps the package portable. The moment a compute function needs a DB handle, it stops working in React Native and offline. So it never gets one.
The router only fetches
The tRPC analytics router does exactly one thing: fetch and filter. No aggregation lives on the server. It returns a match's events, players, lineups, and custom actions in a single roundtrip — and then the client computes everything with the shared package. (Every query is also scoped to the organisation ID as defense-in-depth, but that's about tenancy, not analytics.)
Keeping aggregation out of the router is what guarantees the server and the offline path can't diverge: there's no server-side math to diverge from.
One detail that bit me: position lives in context, not on the event
A subtle modelling call. A player's position (back, wing, pivot, goalkeeper) isn't stamped onto each event — it lives in an AnalyticsContext.playerPositions map, derived from the current roster. Why? Because position is a property of the player in the season, not of a single shot. Stamping it per-event would mean a player who switched positions retroactively corrupts old games, and you'd be duplicating mutable data across thousands of rows. Context-level is the correct altitude.
One more guard: safeRate()
Every percentage in the system goes through a single safeRate(made, total) helper. Sounds trivial, but "shooting percentage" with zero attempts is 0/0 — and a stray NaN rendered into a chart is the kind of bug that makes users distrust every number on the page. One helper, used everywhere, means division-by-zero is handled once and identically across all three runtimes.
What I'd tell past me
- If a number must agree across surfaces, compute it in one place — and make that place portable enough to actually run on all of them.
- Purity is a feature. No DB/DOM deps isn't an aesthetic; it's what lets the function run offline and on native.
- Push the fetch/compute split to the router boundary. Servers fetch, clients compute. The server has no analytics opinions to disagree with.
Honest status: this layer type-checks clean and the architecture holds, but I still owe it a full manual verification against the legacy app's numbers on real tracked data. Build-in-public means admitting the test column isn't green yet.
Next (and last in this arc): the meta post — how one person ships web, native, marketing, and docs out of a single Turborepo without losing their mind.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.