ProjectsTame the Elephant
Tame the
Elephant
A self-discipline PWA where habits earn points and temptations cost them — with the economy underneath engineered like a small bank.
- Role
- Solo · Product + Design + Full-stack + Ops
- Timeline
- Aug 2025 — present · alongside a full-time job
- Status
- Open beta
- Stack
- React · Express · Prisma · PostgreSQL

The Problem
Habit trackers mostly agree on one mechanic: the streak. Which means they also agree on one failure mode: miss a day and the counter — weeks or months of it — goes to zero. The record of your effort is the thing the app threatens you with. I kept watching that pattern produce the same outcome, in myself and in people around me: after the reset, motivation doesn't restart. It quits.
Two more gaps bothered me. Trackers treat temptation as the enemy — there's no legitimate place in them for coffee, a lazy evening, a game session, the things an actual life contains. You either “cheat” or log nothing. And their point systems are decorative: points you can't spend are a score, not an economy, and nothing about a score ever has to be correct.
So the product problem was a discipline system where progress accumulates instead of evaporating and desires are priced instead of forbidden. And the engineering problem followed directly from it: the moment points buy things, you own a small currency — with double-spends, concurrent writes, and consistency obligations a score never has. That second problem is the one I wanted to build properly.
The Concept
The app is built on Jonathan Haidt's metaphor from The Happiness Hypothesis. The emotional mind is an elephant: strong, stubborn, drawn to easy pleasure. The rational mind is its rider: smart, tired, and — most evenings — not in charge. You don't argue with an elephant. You train it: reward the behavior you want, put a price on the behavior you don't.
The metaphor compiles into four entities:
- Habits earn points when checked off. Morning workout — +10.
- Challenges earn points for every day you hold the line and charge a break price when you don't. No sugar — +10 a day, −100 on a break. The break price is deliberately a multiple of the daily reward: holding is profitable, breaking is expensive, and both are numbers, not moral judgments.
- Cravings are legalized temptations: specialty coffee −15, an evening of gaming −40. Spending isn't cheating — it's what the points are for. Without cravings the whole economy is theater.
- Rewards are long-horizon goals (new running shoes — 1,000 points), funded by an automatic percentage split from every earn plus manual deposits.
Streaks exist — day 3 pays ×2, day 7 ×3, day 14 ×4, day 21 ×5 — but they're a bonus curve, not a hostage. The rule I held throughout the design is accumulation over erasure. A break deducts the break price, clamped at whatever balance you have; it never drives the balance negative, never touches reward savings, and the best-streak record stays on the card. There's even a manual “I broke it” action, available at any hour — the product spec's reasoning: without it you either mark “held” (a lie) or leave the day hanging (anxiety), and honesty with yourself is part of the discipline. Every phrase in the app about a break ends with return, not guilt.
The visual language follows the same brief: a rider's field journal. Dark surfaces, monospaced type, stencil headings, gold accents on a drafting grid. No confetti, no motivational quotes, and — per the spec — no neuroscience lectures; the metaphor works at the intuitive level or not at all.
The Solution
What's live today: an installable PWA — React + Vite, offline-precached, in English and Russian — against an Express + Prisma + PostgreSQL API. Sign-up is email+password or Google/GitHub. Home is a dashboard: balance, today's habits and challenges, a points-dynamics chart. Every balance-moving action lands in an events feed that doubles as the ledger, aggregated into dense time buckets for the charts.
The charts themselves — a completion heatmap, bipolar earned/spent bars, a milestone track — are hand-built React components on the design-token system. No chart library was going to match the instrument-panel aesthetic, and the data shapes are simple enough that one wasn't needed.


Architecture
A monorepo with npm workspaces: backend, frontend, shared. The shared workspace is load-bearing — Zod schemas and constants (the milestone curve included) are imported by both sides, so the same schema that validates an API payload on the server powers the form on the client. One source of truth for what a valid habit is.
The part I treat most carefully is the economy. Every feed row is a ledger entry with a hard invariant: Σ(earned) − Σ(spent) = balance. Under Postgres's default READ COMMITTED isolation a plain SELECT locks nothing, so two concurrent requests can both pass a balance check made from the same stale read — and both write. Every read-check-write section (purchases, reward deposits, day-guarded crediting) therefore runs through a per-user, transaction-scoped advisory lock:
The caller's contract is to re-read all checked state inside the lock, never to reuse values fetched before it. A dedicated race suite fires N identical parallel requests against state that permits exactly one: five simultaneous purchases against a balance that covers one must produce one 201, four 400s, and a balance of exactly zero. And behind the lock sits a last line of defense — a database CHECK constraint that keeps the balance non-negative even if some future code path slips past the serialization.
A points economy is a small bank. Once I treated it like one — a ledger with an invariant, serialized writes, race tests in CI — most design questions answered themselves.
Key technical decisions
The demo is engineered, not faked. The one-click recruiter demo — the first thing anyone clicks — is provisioned as exactly the three rows better-auth's sign-in reads, in a single transaction with no outbound mail. Its multi-week history is backdated with the live scoring math, every timestamp anchored at local noon so the check-ins survive the on-read auto-reset in any timezone. The starting balance is computed from the rows the seed writes and asserted against the ledger invariant — a bad seed fails loud instead of shipping numbers that don't add up. Throwaway users are TTL-reaped, and the demo email namespace is reserved so a real sign-up can never be swept.
Time is a product problem. Every day-boundary — streak windows, warning states, auto-resets — is computed in the user's own timezone with DST-safe wall-clock math, so “today” stays correct from Honolulu to Auckland, including the twice-a-year 23- and 25-hour days.
Codes-only error contract. The backend never sends user-facing text: every error is a stable code plus raw params, and the frontend owns all wording through i18next. The contract is enforced at the type level — a backend code with no catalog entry, or an orphaned catalog key no code emits, fails typecheck:
An integration test pins the wire shape itself — required code, raw params, and the guarantee that unclassified 500s leak no internals.
Auth built like it matters. better-auth with DB-backed sessions in httpOnly cookies and Google/GitHub OAuth. Passwords go through Argon2id at OWASP parameters over an HMAC-SHA256 pepper, so a database dump alone isn't crackable; every new password is screened against Have I Been Pwned via the k-anonymity range API — only five characters of a hash ever leave the server. The login path does constant work whether or not the account exists — confirmed by reading the auth library's own sign-in handler, not bolted on — so timing can't reveal which emails are registered. Destructive operations (change password, unlink a provider, delete the account) require a session younger than two hours. Account linking keeps trustedProviders empty, so the provider's email-verified check can't be bypassed — the nOAuth class of account-takeover bugs.
i18n as a build artifact. i18next with types generated from the catalogs: 10 namespaces, English and Russian, extracted and type-checked in CI. Plurals follow CLDR rather than a naive singular/plural pair — English has two categories (one/other), Russian four (one/few/many/other), so 21 is singular-like in Russian but plural in English. The parity check compares plural categories, not key counts, and adding a UI string without both locales fails the build.
PWA details nobody notices until they break. The Workbox precache is hand-tuned: a navigation-fallback denylist so OAuth callbacks and emailed verify links reach the server instead of the cached SPA shell, and iOS splash assets kept out of precache. The splash set is sha-gated in CI against its source SVG — the generated bytes depend on the host Chrome version, so the check gates on the deterministic input, not the nondeterministic output.
nginx in front, backend unreachable. nginx serves the static build and proxies /api/ to the backend over a private Docker network; the backend port is never published. CSP with script-src 'self' (no unsafe-inline for scripts), HSTS, frame-ancestors 'none', rate limiting in three coordinated layers (an nginx burst zone, better-auth's DB-backed limiter, an Express per-minute budget) each keyed by the real client IP recovered from the proxy chain, and hidden sourcemaps that nginx additionally refuses to serve.
Delivery. GitHub Actions: quality gates (lint, typechecks, i18n check, splash hash) → the full integration suite → Docker images to GHCR → Coolify redeploys via webhook. Pull requests build both images without pushing, so a broken Dockerfile can't reach main.
Trade-offs
Things I deliberately didn't build, with the reasoning on record:
- No UI framework, no chart library. The instrument-panel aesthetic would mean fighting a theme at every step, and the chart data shapes are simple. The design system is CSS Modules over hand-rolled design tokens; the charts are plain React and CSS. Cost: a slower first version. Paid once.
- No “motivation” field on habits and challenges. The product spec documents why: the field goes dead in most trackers, it contradicts the metaphor (you don't negotiate with the elephant in words), and it breaks the 30-second create flow of title plus numbers.
- No toasts for routine actions. Check-offs, breaks, and purchases confirm through animation and haptics; toasts are reserved for genuine events. A break intentionally gets no explanatory toast — the red counter animation and the reset series say everything.
- HIBP screening fails open. A Have I Been Pwned outage must not block sign-ups, so the check degrades with a logged warning instead of an error. A conscious availability-over-strictness call.
- Soft email verification. Login is never blocked on an unverified address; a password reset proves mailbox ownership and doubles as verification. Deletion receipts go only to verified addresses — an unverified one may belong to a stranger.
Proof
Engineering facts instead of adoption numbers:
- Cheat-proof points
- zero double-spends
- the economy works like a bank ledger — rapid or simultaneous taps can't spend the same points twice or push a balance negative
- Tested for real
- 400+ database tests
- every points rule, streak bonus, and race condition runs against a real PostgreSQL on every change — not mocked, with a dedicated parallel-request suite proving the economy can't double-spend
- A finished product
- installable & offline
- an installable app that works without a connection, fully in English and Russian
- Serious about security
- hashing + breach checks
- passwords hashed to current OWASP guidance and screened against known breaches; sensitive actions need a fresh login
Tech Stack
The PWA frontend, TypeScript throughout.
Hand-rolled dark “field console” system; no UI framework.
Server state and forms, resolved against shared schemas.
Typed keys generated from the catalogs, en/ru, CI-enforced parity.
Modular API: habits, challenges, cravings, rewards, events, transactions.
Source of truth; advisory locks serialize the per-user economy.
Cookie sessions, Google/GitHub OAuth, two-hour freshness gate for destructive ops.
One schema validates the API payload and powers the client form.
Offline precache, install flow, generated iOS splash set.
Static serving, API proxy, CSP and security headers, request rate limiting.
Tests against Dockerized Postgres, image build, webhook deploy.
See it running
Tame the Elephant is in open beta at tame.day — sign up with an email or via Google/GitHub. The UI ships in English and Russian, installs to a home screen, and works offline. The source is private, so the demo is the artifact: everything claimed on this page is observable in the product, and I'm happy to walk through the code behind any section in a call.