actpact — a habit and challenge tracker for Iranian users

A habit and challenge app with the Jalali calendar underneath it rather than on top of it: Farsi UI, right-to-left throughout, and an SMS login that needs no email address.

Outcome

Live at actpact.ir: about 16k lines of Python, 19 tables and 806 tests, installable as a PWA and shipped as an Android app.

The problem

Habit trackers assume a Gregorian month and a Latin, left-to-right interface, and an Iranian user lives in neither. A monthly quota — "twelve sessions this month" — has to reset on the first of Farvardin, not the first of March; a Gregorian month straddles two Jalali ones, so bucketing by the Gregorian month splits one member's quota across two periods and nobody can tell why their count halved.

A FastAPI application with a server-rendered Jinja2 front end, and a Capacitor shell around the deployed site for Android.

The calendar goes all the way down. Occurrence keys are Jalali, period boundaries are real Jalali boundaries, and weeks start on Saturday. The conversion is arithmetic with no dependency, and its test walks all ~44,000 days from 1300 to 1420 against ICU's Persian calendar — the same authority the browser formats with, so the server cannot bucket a date into a month the UI labels differently.

"Today" is a question about the member, not the server. Each enrolment carries its own timezone, so the same UTC instant yields different occurrence keys for two people in different zones. That is why the occurrence engine is a pure module with no database and no routes: it can be tested exhaustively, and it is. Check-ins are idempotent through a unique constraint — on a phone with poor signal a retry is the normal case, not an error.

Authorization is a SQL predicate, not an if. Object access is four composed WHERE clauses, so a row the caller may not see never loads at all and a miss falls out of the query as "no such row" rather than as a decision taken after the fact. Four role systems — app, challenge, group, course — are deliberately never merged, and the suite asserts what an operator cannot do: admin grants moderation and deletion and not CHALLENGE_EDIT, because changing what state a challenge is in is moderation and changing what it says is authorship.

Nothing that can be derived is stored. Streaks are recomputed by walking the expected occurrences backwards; there is no += 1 anywhere in the codebase. Leaderboard rank is counted live, because a monotonic counter would rank an abandoned challenge above a live one.

Web Push is written straight against RFC 8291 over RFC 8188 with an RFC 8292 VAPID assertion — about a hundred lines, one dependency instead of three. The ordering mattered more than the crypto: a push is dispatched only once the transaction is known to have ended, because there is no un-sending a notification that reached a lock screen before its commit.

No bundler, no framework, no node_modules in the web app. The service worker caches static assets and refuses to cache a page of HTML — every screen computes its rings and counters live, and a cached page is a page of confidently wrong numbers. The repository is private because this is a product I intend to run; the architecture is documented and I am happy to walk through any of it.