• react-native
  • jewelry-retail

JewelCalc: a jeweler's price calculator, RN + Node + Python

Published on
Talk about a full-time role

private repos, commit count

548 commits across three repos, 269 / 260 / 19

Role
Creator, solo developer
Duration
2025-06-28 to 2026-08-18, two bursts
Technology
React Native / Expo, Node/Express, Python, Redis, Railway
Scale
3 repos, 548 commits, 112,074 lines of code

JewelCalc prices jewelry for a shop that sells it. One phone computes the price from the day's metal rate, and a second app on a paired device shows the customer the same number. I built all three pieces alone: a React Native calculator, a Node gateway with a Python scraper behind it, and the customer display. The git record holds 548 commits across three private repositories, 269 / 260 / 19, between 2025-06-28 and 2026-08-18, counted on 2026-08-27 (project audit §2 rows 2 and 3).

The repositories are private, so this page carries no links. The shop is not named and no live endpoint is printed here.

One price, computed in one place

The shop's price is built from the metal rate of the day, the weight of the piece and the shop's own cost data. The owner works from a phone, and that phone is the system of record by an explicit decision (§2 row 125). Everything else in the system reads from it. The customer display exists so the buyer sees the same figure the owner is quoting, without a second machine arriving at its own answer.

What the restart had to survive

The project ran in two bursts with roughly 11 months between them (§2 row 4), and the restart brief opens by naming the dormancy (§2 row 114). When I came back, the core function was dead: the metals upstream returned 404, so no price could be calculated at all (§2 row 115). A clean install failed 16 of 18 suites and 106 of 226 tests, measured 2026-08-03 in the repo's own brief (§2 row 116).

Two constraints stayed for the whole build. TypeScript strict is off in the calculator app and 18 typecheck errors predate the work, a count the CI comment states out loud (§2 row 40), so the compiler catches nothing on the money paths. And pushing to GitHub deploys nothing: CI runs tests, while a deploy is a manual railway up per service, a fact the workflow header declares in capital letters (§2 row 28).

The three problems that took the time

A second language across 613 call sites, without editing them. Every string in the app was read as an object property from 613 places, many of them on screens that handle money. The usual fix is a translation function, which means touching all 613 expressions. Instead the bundle walks the English strings once at import and installs a getter on every leaf, so the object stays an object and the language moves underneath it (audit §3). A Proxy was rejected in writing, because Object.keys and spread over a Proxy behave in ways that are hard to verify on a device you cannot attach a debugger to, and the parity test needs the object to stay walkable.

A customer screen that cannot compute a price. If the display could recompute a price, it could disagree with the phone, and the shop would be quoting two numbers to one customer. The display renders strings the calculator already formatted. That rule is enforced by a shell script in npm run verify rather than by review: one check forbids any arithmetic operator inside the app's components and screens, which was only writable after sizing and timing math were moved out into seven named hook and util files (audit §3). Another check blocks any reference to the catalogue, because that data carries the owner's acquisition costs and the screen faces customers.

A price that states how old it is. Freshness is modeled as a three-case value: known, atLeast, unknown. With strictNullChecks off, a nullable number collapses into a plain number and the caller gets no warning, while a literal tag survives non-strict mode and cannot be silently compared against a threshold and come out "fresh" (audit §3). atLeast covers the common real case, a successful fetch that carries no timestamp, where the time since the fetch is a valid lower bound. The device clock is treated as evidence and checked against the backend's own age figure at fetch time, so detecting a wrong clock needs no correct clock. On the server, the refresh interval published to the phone is the scheduled one and never the inflated backoff interval, so a failing scraper cannot widen the alarm built to catch it.

What runs in production

The system runs as three services on Railway, a host that builds each one from a Dockerfile in the repo. They are Redis 8.2.1, an in-memory data store, a public Node 22 API gateway, and a private Python 3.11 scraper (§2 row 31). The scraper container runs two processes under supervisord, the web process and the scheduler (§2 row 32). Every /api/* path requires a shared-secret header and returns 401 without it, checked path by path on 2026-08-27 (§2 row 23). The health endpoint proves its persistent volume is mounted by reporting the device-number comparison it used, instead of asserting that the volume is fine (§2 row 22). The gateway answered that health call with HTTP 200 in 0.356 s on 2026-08-27 (§2 row 21).

One limit is written into the code that depends on it: there is exactly one gateway replica, and the comment says this is a fact about the current deployment rather than a guarantee the platform enforces, then names the fix a second replica would need (§2 row 34).

How the record is kept

62 numbered decision records exist across the three repos, 35 in the app, 19 in the gateway, 8 in the display (§2 row 10). Open defects live in a separate list from decisions, and the distinction is stated: nothing in the defect list is locked, and a defect becomes a numbered decision only when it is fixed or deliberately accepted (§2 row 120). The gateway list holds 10 numbered defects, 7 open and 3 struck through as fixed and left visible; the app list holds 4, with sections for what is deliberately not listed (§2 row 121). One production fix was verified against the live service with two calls three seconds apart and the rollback deployment id recorded (§2 row 122). One defect is filed as a single red run in five, written with what is not known about it (§2 row 123). The install runbook documents a pairing deadlock that has no in-app escape, and corrects its own earlier version, which would have cost the shop its records (§2 row 124).

Where the gates stand, measured 2026-08-27

GateResultAudit row
Calculator app tests80 suites, 1,624 tests, 0 failures, 17.6 s§2 row 35
Gateway tests24 suites, 396 tests, 394 passed, 2 skipped, 7.9 s§2 row 36
Display app npm run verifytypecheck, money-math gate and 71 tests in 7 suites, exit 0§2 row 37
Calculator lintexit 0, 113 warnings, 0 errors§2 row 38
Gateway lintexit 0, 97 warnings, 0 errors§2 row 39
Calculator typecheck18 errors, matching the count CI claims§2 row 40
Gateway typecheck0 errors§2 row 42

Of the 18 typecheck errors, 14 sit in test files and a test helper, and 2 are in shipped source (§2 row 41). One gate fails on purpose: the brand-asset check exits 1 while the app icons are still the framework placeholders, and CI marks it blocking, which keeps a store release from shipping placeholder art (§2 row 43).

The written record is a large part of the work. Tracked code across the three repos is 112,074 lines (§2 row 5). In the calculator app, 49,614 lines are source and 25,906 are tests and mocks (§2 row 6). The gateway repo carries more Markdown files than TypeScript files, 105 against 63 (§2 row 7).

Stack

React Native / Expo 0.81.5 on SDK 54 for both apps, Node/Express for the API gateway, Python with FastAPI for the scraper service, Redis 8.2.1, Docker, Railway for hosting, GitHub Actions for CI with deploys run by hand.

Discuss a mobile or pricing-system build