<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jonas</title>
    <description>The latest articles on DEV Community by Jonas (@jofflin).</description>
    <link>https://dev.to/jofflin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4007371%2Fc9c86b18-c55a-4191-b02d-5036cff53395.png</url>
      <title>DEV Community: Jonas</title>
      <link>https://dev.to/jofflin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jofflin"/>
    <language>en</language>
    <item>
      <title>One analytics codebase, three runtimes (web, native, offline)</title>
      <dc:creator>Jonas</dc:creator>
      <pubDate>Wed, 29 Jul 2026 10:55:34 +0000</pubDate>
      <link>https://dev.to/jofflin/one-analytics-codebase-three-runtimes-web-native-offline-4jf2</link>
      <guid>https://dev.to/jofflin/one-analytics-codebase-three-runtimes-web-native-offline-4jf2</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is post #3 in my build-in-public series about &lt;a href="https://sportsflow.de" rel="noopener noreferrer"&gt;SportsFlow&lt;/a&gt;. The fix was to make the analytics a single thing that runs in all three places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape: a pure-compute package
&lt;/h2&gt;

&lt;p&gt;There's a package, &lt;code&gt;@sportsflow/analytics&lt;/code&gt;, with one hard rule: &lt;strong&gt;no database dependencies, no DOM dependencies.&lt;/strong&gt; It's just functions. Events in, numbers out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;match_event rows  ──adapter──▶  AnalyticsEvent[]  ──compute──▶  KPIs / heatmaps / momentum
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it's pure, the &lt;em&gt;same&lt;/em&gt; functions run in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The web app&lt;/strong&gt; — over data fetched via tRPC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The native app&lt;/strong&gt; (Expo) — same import, same results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline, during live tracking&lt;/strong&gt; — over the in-memory event queue, before anything has even synced to the server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;em&gt;is&lt;/em&gt; the real one, fed from local events. When the game ends and the data syncs, the season numbers are computed by the same code. &lt;strong&gt;No drift, by construction.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoupling from the database
&lt;/h2&gt;

&lt;p&gt;The trick that makes this work is the &lt;code&gt;AnalyticsEvent&lt;/code&gt; type — a DB-decoupled view of an event with all the metadata it needs &lt;em&gt;resolved inline&lt;/em&gt;: 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 &lt;code&gt;adapter&lt;/code&gt; turns raw &lt;code&gt;match_event&lt;/code&gt; rows (and custom-action definitions) into &lt;code&gt;AnalyticsEvent&lt;/code&gt;s, and from there it's all pure transformation.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The router only fetches
&lt;/h2&gt;

&lt;p&gt;The tRPC analytics router does exactly one thing: &lt;strong&gt;fetch and filter.&lt;/strong&gt; 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 &lt;em&gt;also&lt;/em&gt; scoped to the organisation ID as defense-in-depth, but that's about tenancy, not analytics.)&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;from&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One detail that bit me: position lives in context, not on the event
&lt;/h2&gt;

&lt;p&gt;A subtle modelling call. A player's position (back, wing, pivot, goalkeeper) isn't stamped onto each event — it lives in an &lt;code&gt;AnalyticsContext.playerPositions&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more guard: &lt;code&gt;safeRate()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Every percentage in the system goes through a single &lt;code&gt;safeRate(made, total)&lt;/code&gt; helper. Sounds trivial, but "shooting percentage" with zero attempts is &lt;code&gt;0/0&lt;/code&gt; — and a stray &lt;code&gt;NaN&lt;/code&gt; rendered into a chart is the kind of bug that makes users distrust &lt;em&gt;every&lt;/em&gt; number on the page. One helper, used everywhere, means division-by-zero is handled once and identically across all three runtimes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If a number must agree across surfaces, compute it in one place&lt;/strong&gt; — and make that place portable enough to actually run on all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purity is a feature.&lt;/strong&gt; No DB/DOM deps isn't an aesthetic; it's what lets the function run offline and on native.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push the fetch/compute split to the router boundary.&lt;/strong&gt; Servers fetch, clients compute. The server has no analytics opinions to disagree with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>architecture</category>
      <category>react</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Shipping 4 apps solo from one Turborepo</title>
      <dc:creator>Jonas</dc:creator>
      <pubDate>Mon, 20 Jul 2026 09:07:48 +0000</pubDate>
      <link>https://dev.to/jofflin/shipping-4-apps-solo-from-one-turborepo-340e</link>
      <guid>https://dev.to/jofflin/shipping-4-apps-solo-from-one-turborepo-340e</guid>
      <description>&lt;p&gt;&lt;a href="https://sportsflow.de" rel="noopener noreferrer"&gt;SportsFlow&lt;/a&gt; is four apps: a web app, a native (Expo) app, a marketing site, and a docs site. I'm one person. The only way that's sustainable is to make the four apps share as much as possible &lt;em&gt;without&lt;/em&gt; coupling them into a tangle.&lt;/p&gt;

&lt;p&gt;This is the final post in my build-in-public arc. It's the meta one: the conventions that let one brain hold the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/
  web/        the product            (TanStack Start)
  marketing/  the public site        (TanStack Start, no auth/db/trpc)
  fumadocs/   the docs
  native/     mobile                 (Expo)
packages/
  ui/         design system + app-agnostic court geometry
  api/        tRPC router
  auth/       better-auth + org + stripe plugins
  db/         Drizzle schema + relations
  analytics/  pure-compute stats (runs everywhere)
  env/        t3-env validated config
  config/     shared tsconfig
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turborepo + pnpm. The pnpm &lt;strong&gt;catalog&lt;/strong&gt; pins shared dependency versions in one place, so all four apps move in lockstep instead of slowly drifting into four different React versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conventions are the real architecture
&lt;/h2&gt;

&lt;p&gt;When you're solo, you can't rely on review to enforce consistency — there's no second person. So the rules have to be &lt;em&gt;structural&lt;/em&gt;, the kind a type-checker or a linter catches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zod-first types.&lt;/strong&gt; Every type is a zod schema + &lt;code&gt;z.infer&lt;/code&gt;. No standalone interfaces anywhere. This isn't a style preference; it means runtime validation and compile-time types are the same artifact and can't drift. Value vocabularies live as code constants + zod, not Postgres enums — extending a list is a deploy, not a migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tRPC procedure ladder.&lt;/strong&gt; Authorisation is a staircase, and you physically cannot skip a step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;publicProcedure&lt;/span&gt;
  &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nf"&gt;protectedProcedure   &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nf"&gt;orgProcedure       &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="nx"&gt;team&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="nx"&gt;membership&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;injects&lt;/span&gt; &lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nf"&gt;staffProcedure   &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;staff&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="nx"&gt;only&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every domain query filters on &lt;code&gt;orgId&lt;/code&gt;, and &lt;code&gt;orgProcedure&lt;/code&gt; is the thing that &lt;em&gt;provides&lt;/em&gt; &lt;code&gt;orgId&lt;/code&gt; to the context. You can't write a query that forgets tenancy scoping, because the scoping comes from the procedure you built on. Multi-tenant safety becomes a property of where you start, not a checklist you remember.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One design system, two consumers.&lt;/strong&gt; &lt;code&gt;packages/ui&lt;/code&gt; holds brand-locked, app-agnostic atoms — &lt;code&gt;Button&lt;/code&gt;, &lt;code&gt;ScorePill&lt;/code&gt;, &lt;code&gt;PlayerChip&lt;/code&gt; — plus the &lt;em&gt;court geometry&lt;/em&gt;: the SVG math for a handball court, goal zones, and play playback. Both the web tracking UI and the marketing site's live demos render the same court from the same geometry. The marketing animations aren't a reimplementation; they're the real component with demo data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Billing stays out of the domain.&lt;/strong&gt; Subscriptions are per team (&lt;code&gt;referenceId = orgId&lt;/code&gt;) via better-auth's Stripe plugin. But domain tables know &lt;em&gt;nothing&lt;/em&gt; about billing — tier limits live in one file (&lt;code&gt;limits.ts&lt;/code&gt;, e.g. &lt;code&gt;assertCanCreateMatch&lt;/code&gt;) called from the API. The schema never grows a &lt;code&gt;plan&lt;/code&gt; column. If pricing changes, exactly one file changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the monorepo buys me concretely
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Change a type once.&lt;/strong&gt; A field added to a zod schema in &lt;code&gt;db&lt;/code&gt; ripples through the tRPC router, the web app, and the native app as type errors — a to-do list the compiler writes for me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same analytics run everywhere&lt;/strong&gt; (see post #3) precisely &lt;em&gt;because&lt;/em&gt; it's a shared package, not copy-paste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing has no auth/db/trpc at all.&lt;/strong&gt; It's deliberately a different dependency graph — a fast static site that imports only the design system. The monorepo lets me share the &lt;em&gt;look&lt;/em&gt; without sharing the &lt;em&gt;weight&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Honesty section. A monorepo isn't free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native is early.&lt;/strong&gt; It's scaffolded and has a first cut of season analytics, but it lags the web app. Sharing packages helps, but React Native still needs its own UI primitives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The better-auth schema is generated&lt;/strong&gt;, which means a re-merge checklist every time (strip the old relations blocks, since relations live centrally for the new query builder). Generated code in a monorepo needs guardrails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One person is still one person.&lt;/strong&gt; The tooling multiplies what I can build; it doesn't multiply the hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The throughline
&lt;/h2&gt;

&lt;p&gt;Across this whole series — offline-first capture, cross-platform analytics, this monorepo — the same idea keeps showing up: &lt;strong&gt;make the correct thing structural.&lt;/strong&gt; Idempotency in the schema instead of the network. Tenancy in the procedure instead of the query. Types from one zod definition instead of two. Stats from one pure package instead of three implementations.&lt;/p&gt;

&lt;p&gt;When you're solo, discipline you have to &lt;em&gt;remember&lt;/em&gt; will eventually fail. Discipline the system &lt;em&gt;enforces&lt;/em&gt; is the only kind that scales to one person building four apps.&lt;/p&gt;

&lt;p&gt;Thanks for following the series. If you're building something in this space — live sports, offline-first, multi-tenant SaaS, solo — come say hi.&lt;/p&gt;

</description>
      <category>monorepo</category>
      <category>typescript</category>
      <category>turborepo</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Hi, I'm Jonas — building a sports SaaS solo, in the open</title>
      <dc:creator>Jonas</dc:creator>
      <pubDate>Mon, 29 Jun 2026 06:25:52 +0000</pubDate>
      <link>https://dev.to/jofflin/hi-im-jonas-building-a-sports-saas-solo-in-the-open-odd</link>
      <guid>https://dev.to/jofflin/hi-im-jonas-building-a-sports-saas-solo-in-the-open-odd</guid>
      <description>&lt;p&gt;Hi 👋 I'm Jonas.&lt;/p&gt;

&lt;p&gt;CS bachelor, Entrepreneurship master. By day I'm at &lt;strong&gt;nono&lt;/strong&gt;. On the side I'm building &lt;strong&gt;SportsFlow&lt;/strong&gt; solo — and I'm going to write about every hard part of it out in the open. This is the intro.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm building
&lt;/h2&gt;

&lt;p&gt;SportsFlow replaces the thing every amateur handball coach still uses: a clipboard and a pen.&lt;/p&gt;

&lt;p&gt;The idea is simple. Live-track every shot, assist and save during the game on a phone or tablet, and get real season analytics out the other end — shooting percentages, heatmaps, goalkeeper saves, momentum, lineup impact. Handball first, then volleyball, basketball, ice hockey.&lt;/p&gt;

&lt;p&gt;I sat on enough benches to know the problem is real: the data is right there &lt;em&gt;in the game&lt;/em&gt;, and it evaporates the second the whistle blows. Nobody should need a spreadsheet and a good memory to coach with numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I write about both code and product
&lt;/h2&gt;

&lt;p&gt;I build at the seam between engineering and product — that's the CS + Entrepreneurship combo. So I won't only post architecture. I'll also post the decisions about &lt;em&gt;what's worth building at all&lt;/em&gt;: where I drew scope lines, what I deliberately didn't build, how billing shapes the data model.&lt;/p&gt;

&lt;p&gt;The recurring theme in everything here is one idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Make the correct thing structural.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Idempotency in the schema, not the network. Tenancy in the procedure, not the query. Types from one zod definition, not two. Discipline the system enforces, not discipline you have to remember — because when you're solo, the stuff you have to remember eventually fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll get if you follow along
&lt;/h2&gt;

&lt;p&gt;Biweekly build-in-public deep-dives, including the honest costs and not just the wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline-first capture&lt;/strong&gt; — sports halls have zero WiFi, so the whole tracking pipeline works offline and reconciles later without double-counting goals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One analytics codebase, three runtimes&lt;/strong&gt; — the same shooting-percentage code runs in the web app, the native app, and offline during live tracking, so the numbers can never disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping four apps solo from one monorepo&lt;/strong&gt; — web, native, marketing, and docs, and the conventions that make that sane for one person.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;If that's your filter: TypeScript (zod-first), TanStack Start, tRPC, Drizzle, better-auth, Turborepo, Expo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Come say hi
&lt;/h2&gt;

&lt;p&gt;If you're building in live sports, offline-first systems, or multi-tenant SaaS — or you're a solo dev shipping more than one person reasonably should — follow along, and please come argue with me in the comments. The build-in-public part only works if it's a conversation.&lt;/p&gt;

&lt;p&gt;First deep-dive drops soon.&lt;/p&gt;

</description>
      <category>introduction</category>
      <category>buildinpublic</category>
      <category>typescript</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
