Skip to content
Codeino
Work2026 · Client project · live in production

Nafs Tracker

An Arabic-first worship tracker for daily Islamic practice — prayers, adhkār, a full muṣḥaf, fasting and more — rebuilt from a single 8,000-line file into a tested module system, then redesigned so it opens on the rhythm of your day rather than on a score.

The client's own brief was one sentence: a Muslim should stay in the app out of love, not obligation. Everything below hangs off it.

The Nafs Tracker identity — gold on deep green
Role
Audit, re-architecture, redesign
Scope
22 screens, PWA, offline
Stack
Vanilla JS ES modules · Firebase · Tailwind
Shipped
Live, in production
01

The brief

Nafs Tracker was already live and already had users. This was not a greenfield build: it was one `app.js` of 7,952 lines, a 4,400-line stylesheet, and a feature set that had grown faster than its structure — 22 screens, Firebase auth and Firestore, a service worker, and five external API integrations.

The ask had three parts, in order. Find everything that is wrong with it — not a smoke test, a full read. Fix all of it, and reorganise the project so the next change is easy. Then redesign it so that a Muslim stays in the app out of love, not obligation. That last line is the client's own, and it is the sentence the whole design brief hangs on.

One constraint shaped the work: the app was in production with real worship history in Firestore. Nothing could be rebuilt in a way that lost or corrupted a single day of someone's record. That ruled out a rewrite and made this a refactor in place, with a migration path for every schema change.

02

What shipped

  • A full manual audit of 15,447 lines across 33 files — 43 findings, each ranked and tied to a file and a line
  • 41 of the 43 fixed, including three that were silently corrupting user data
  • `app.js` split into 53 ES modules with a one-way rule: features import from core, never the reverse
  • A design system — six ground depths, semantic colour, a spacing and motion scale — replacing hardcoded hex throughout
  • Dashboard, header, login, prayer, Qur'an and muṣḥaf screens rebuilt on it
  • 38 Jest tests where there were none, covering the scoring that had been wrong and the escaping that had been unsafe
  • Security headers unified to one source, the content security policy corrected, HSTS added
  • Deployed to production on the existing URL, with a data migration that runs on each user's first load
issues found in audit
43
modules, from one file
53
tests, from zero
38
lines of JS, CSS and HTML
19,562
03

Decisions, and why

Anyone can list a stack. The useful part of a case study is the calls that had a real alternative — so each of these says what the tradeoff was, not just what was chosen.

  1. 01

    The dashboard opens on the day, not on the score

    Next prayer, a verse chosen for the hour, and one invitation. Streak and points moved under a “your journey” heading.

    Six equal cards of counters — streak, points, pages, challenge, adhkār, khatmas — gave a person coming to worship a scoreboard and no hierarchy. The client asked for love rather than obligation, and the counters were the obligation. Nothing was deleted: the numbers still exist one section further down, at a size that lets you find them rather than making you read them.

    Tradeoff · Progress is a scroll away instead of the first thing you see. For an app that keeps score of worship, that is the intended cost and the client asked for it by name.

  2. 02

    The muṣḥaf is a leaf, not a document

    A bounded page with a ruled gilt frame, justified lines, an ornamental cartouche per sūrah and rosette āyah markers.

    The reading screen rendered scripture as flowing web text: ragged right, flat background, a decorative character either side of the sūrah name. Justification is the single strongest cue — every line of a Madani muṣḥaf reaches both margins, and ragged-right is what makes Qur'an text on the web read as a website. On top of that: paper ground with fibre texture, a double gilt rule, a cartouche drawn in SVG, āyah numbers inside eight-pointed rosettes, a folio medallion, and a running head carrying sūrah, juz and ḥizb. Night mode is a tanned-down leaf rather than white-on-black, because long reading by lamplight is what it is for.

    Tradeoff · An exact page match needs the per-page QCF fonts — 604 files, one per page, metric-designed so every page is exactly 15 lines. Amiri Quran with type fitted by measurement is a far smaller download and lands close.

  3. 03

    Scripture is never clipped

    The type is fitted to the leaf by binary search, with a 17px floor. Below that, the leaf scrolls.

    Fitting text to a fixed page is a measurement problem: set a size, measure, halve the interval. But a dense page on a small phone does not fit at any size a person can comfortably read. The choice was between shrinking the script until it fits and letting the page scroll. Legibility won, and it is enforced in code: hiding part of an āyah is not an acceptable outcome for this app.

    Tradeoff · On a small phone a dense page is not one screen. It keeps the frame, the cartouche and the folio — it just moves.

  4. 04

    Points are derived, not accumulated

    `totalPoints` is recomputed from the logs on every change; one-off awards are recorded, not added.

    The app awarded points from six places. Five wrote into a running total; one recalculated that total from scratch. So finishing the daily challenge showed +10, and the next time you logged a prayer the recalculation erased it — along with the fasting and tasbīḥ awards. Users were losing points they had earned and had no way to describe the bug. The fix was to pick one model and hold it: the total is derived from stored activity, so every source must be representable, and one-off awards now go into a date-keyed map that the recalculation reads.

    Tradeoff · Existing users carried those awards inside a stored total the new derivation could not explain. A one-time migration parks the unexplained surplus as a legacy bonus, so nobody's number moved on the day it shipped.

  5. 05

    Escaping is chosen per context, not per value

    HTML escaping in HTML, backslash escaping inside inline JavaScript.

    `escapeHtml` was used everywhere, including inside `onclick` attributes. The browser decodes `'` back to an apostrophe *before* parsing the attribute as JavaScript, so an apostrophe closed the string and everything after it ran. A custom tasbīḥ phrase with an apostrophe was enough to break the page; a crafted backup file was enough to execute code with the user's Firebase session. Ten call sites now use a helper that escapes for the JavaScript layer first and the HTML layer second, and imported backups are filtered against an allow-list of keys with every id re-validated.

    Tradeoff · The inline handlers stay inline for now. The helper is documented as a bridge for existing markup rather than a pattern to copy, and delegated listeners are the next step.

  6. 06

    The document scrolls, not the body

    `html` and `body` lost their fixed height and their own overflow.

    `html { height: 100% }` plus `body { overflow-y: auto }` had quietly made the body its own scroll box. `window.scrollY` was therefore always 0 — which meant the back-to-top button did nothing, the router's scroll-to-top on screen change did nothing, and mobile browsers could never collapse their address bar. Three visible features were broken by two CSS declarations, and none of them looked like a CSS problem.

    Tradeoff · A nested scroll container gives finer control over what moves. Giving it up buys back the platform behaviours the app was fighting.

  7. 07

    Taps paint before they save

    The screen re-renders from local state, then the write goes to Firestore.

    Every prayer button awaited a Firestore round trip before updating the screen. On a slow connection the tap felt dead; when the write failed it hung for seconds. Rendering first makes the tap land in one frame — measured at 80ms — and the save follows.

    Tradeoff · A failed write leaves the screen ahead of the server for a moment — and the save path now surfaces its own failure, which it previously swallowed entirely.

  8. 08

    One source of security headers

    `vercel.json` only. The `<meta>` policy and the `_headers` file were removed.

    There were three content security policies — a meta tag, `vercel.json`, and a Netlify-format `_headers` — and they had drifted apart. Browsers apply the *intersection* of a header policy and a meta policy, so each host silently blocked a different set of features, and `firebase.json` carried no headers at all. Unifying them also fixed a live bug: `ipapi.co` was called by the code and allowed by none of the three, so IP-based location was blocked in production.

    Tradeoff · The meta policy was the only protection when serving the folder directly during development. That is now a documented difference between local and deployed.

04

The audit

The audit read all 15,447 lines rather than sampling, and produced 43 findings: 5 critical, 11 high, 14 medium, 13 minor. Each names a file and a line and states the failure concretely.

The three that mattered most were invisible from the outside, because each produced wrong data rather than an error message: points being erased by the next recalculation, Qur'an pages scoring eight instead of five, and the heatmap data being computed on every change and then never written — so the activity map only ever showed the current day, and had done since it shipped.

Two more were live features that had never worked at all. The offline write queue was dead code: its enqueue function was defined and never called. And the four PWA shortcuts in the manifest pointed at `?screen=` parameters that no code read, so all four opened the home screen.

The report also corrected itself, which is the part worth keeping. One finding claimed eight duplicated CSS selectors; measuring the property overlap showed six of them were ordinary cascade layering and only two were genuinely dead. Both were removed and the claim was revised rather than quietly acted on. A finding you cannot reproduce is not a finding.

05

Quieting the interface

The old interface put gold on every heading, border and button, alongside stray Tailwind blues, yellows and greens that had nothing to do with the identity. When everything is gold, nothing is. Depth now comes from six stacked greens rather than from outlines, semantic colour is a muted set of three, and every hardcoded hex in the stylesheet was replaced by a token — so the palette is one edit rather than a search-and-replace.

Motion got the same treatment. `animate-pulse` had been running permanently on static numbers, and motion without a reason to move is noise — it is also most of what makes an interface read as dated. What survives is a fourteen-second breath behind the next prayer: slow enough to ignore, and the one place where a living thing is being represented. `prefers-reduced-motion` is honoured throughout.

Screens that had leaned on colour to separate their sections had to find that separation in spacing and type instead, which meant rebuilding them rather than restyling them. They are being rebuilt in pairs, verified and deployed, so any regression is bounded to two screens rather than twenty-two.

06

What was deliberately left out

Scope discipline is part of the work. Each of these was considered and cut for a stated reason.

A rewrite
The app is in production with real worship history. Every change had to be a migration, not a replacement.
A bundler
The app deploys as raw ES modules with no build step. Adding one introduces a step that can be forgotten before a deploy.
Removing the points system
The client chose to keep it and quiet it. Deleting it would have been a product decision, not a design one.
Per-page QCF Qur'an fonts
604 font files for an exact page match. Amiri with measured fitting is a smaller download and close enough.
Redesigning all 22 screens at once
Screens are rebuilt in pairs, verified and deployed, so a regression is bounded to two screens.

No user numbers, revenue or performance improvements are claimed here, because none were measured. What is verifiable is the counts on this page, the 38 passing tests, and the live URL. Figures were checked against the repository on 5 September 2026.

Stack

  • JavaScript
  • ES Modules
  • Firebase
  • Firestore
  • Tailwind CSS
  • Jest
  • Vercel

Services

  • Code audit
  • Re-architecture
  • Product design
  • Design system
  • Frontend engineering

Need something built to this standard?

We design and build complete systems — a purchase path that works, roles, and an admin panel that actually runs the business. And Arabic here is never a translation layer bolted on at the end.