Project

Skaii-Cali

An offline-first calisthenics training tracker. A monorepo holding a .NET 10 API, an Expo React Native application, and the shared TypeScript packages that sit between them.

The same arithmetic, written twice on purpose

The tracker computes things about a set: tempo, volume, the distance and duration and pace triple, whether a result qualifies as a personal best, where rest belongs in a routine. Those computations have to run in two places. On the device, because the application works offline and a set logged in a basement gym has to be scored before it ever reaches a server. On the server, because the device is not the authority on an athlete's history.

The usual answer is to write it once and share it. That is not available here: the server is C# and the device is TypeScript, and a shared runtime between them would mean either shipping a .NET runtime to a phone or moving the domain into JavaScript on the server. So the arithmetic is authored twice, deliberately, and the interesting part is what holds the two copies to each other.

Shared vectors, read by both suites

Every rule is written down as a JSON vector file. The vectors sit in the specification directory, not inside either implementation, and both test suites read the same files: an xUnit runner on the C# side and a Vitest runner on the TypeScript side. There is one file per rule, covering tempo, the distance triple, volume, personal-best condition keys, rest placement, target values, routine structure, derived provenance, whitespace handling, and engine divergence itself.

The consequence is the point. A change to any of that arithmetic means adding or adjusting a vector first, then updating both implementations, then running both suites. A vector that passes in one language and fails in the other is a divergence, and it fails a test rather than silently scoring one athlete's sets differently depending on whether they were online at the time.

The details that look like trivia and are not

Personal-best condition keys are formatted through a normalisation step that turns negative zero into zero. That exists because .NET writes -0 where JavaScript writes 0, and an un-normalised pair splits one athlete's personal-best lineage into two. It is a two-character difference that quietly loses somebody's record.

Non-finite values get the same treatment. A client can send a magnitude large enough to overflow to infinity, which then passes a naive check for being greater than zero, so there are explicit finiteness guards across the validators and the domain readers. Values a person would never type are reachable input, and refusing them is cheaper than discovering later that a stored total saturated.

Never lose an athlete's data, and never quietly merge it

The second rule the codebase holds itself to is that athlete data is never lost and never silently collapsed. Absent is not zero. Sides do not merge. A qualifying condition travels with the result it qualifies. A conflict surfaces rather than being resolved on the athlete's behalf.

Sync is where that gets tested. The application is offline-first, so work is written locally and queued in an outbox, a durable local record of changes waiting to be pushed. A push that fails leaves the queue intact rather than dropping it. A pull that would overwrite a locally dirty row has to answer for it instead of winning by default. The design brief for the whole layer is short: nothing an athlete recorded may disappear because the network was bad.

How it is put together

One monorepo, three deployables, and a set of shared packages.

  • A .NET 10 backend using FastEndpoints with Dapper over Npgsql on PostgreSQL, a thin Blazor Server operator application, and a DbUp migrator.
  • An Expo React Native application, which is the primary surface, built with expo-router and native builds via EAS.
  • Shared TypeScript packages: the domain core, a generated API client, and the sync engine.

The backend layering runs contracts, then a web-contracts layer that turns a result type into a problem-details response, then the application module holding services and repositories and the domain, then the FastEndpoints host. Interfaces live in the same file as the implementation they describe. Every layer boundary returns a result type rather than throwing across it, with the deliberate exception of the sync push session, where the row counts it returns are the thing the caller actually needs.

Source on GitLab