actpact — ein Gewohnheits- und Challenge-Tracker für iranische Nutzer

Eine Gewohnheits- und Challenge-App, in der der Jalali-Kalender nicht obenauf liegt, sondern ganz unten: persische Oberfläche, durchgehend rechts-nach-links, Anmeldung per SMS ohne E-Mail-Adresse.

Ergebnis

Live unter actpact.ir: rund 16.000 Zeilen Python, 19 Tabellen und 806 Tests — als PWA installierbar und als Android-App ausgeliefert.

Das Problem

Habit-Tracker setzen einen gregorianischen Monat und eine lateinische Oberfläche von links nach rechts voraus — iranische Nutzer leben in beidem nicht. Ein Monatskontingent („zwölf Einheiten diesen Monat") muss am 1. Farvardin zurückgesetzt werden, nicht am 1. März. Ein gregorianischer Monat überlappt zwei Jalali-Monate: Wer nach dem gregorianischen Monat gruppiert, zerlegt das Kontingent eines Mitglieds in zwei Perioden, und niemand versteht, warum sich der Zähler halbiert hat.

Eine FastAPI-Anwendung mit serverseitig gerendertem Jinja2-Frontend und einer Capacitor-Hülle um die deployte Seite für Android.

Der Kalender reicht bis ganz nach unten. Occurrence-Schlüssel sind Jalali, Periodengrenzen sind echte Jalali-Grenzen, und Wochen beginnen am Samstag. Die Umrechnung ist reine Arithmetik ohne Abhängigkeit, und ihr Test läuft alle rund 44.000 Tage von 1300 bis 1420 gegen den persischen Kalender von ICU — dieselbe Instanz, mit der der Browser formatiert. Der Server kann ein Datum also nicht in einen Monat einsortieren, den die Oberfläche anders beschriftet.

„Heute" ist eine Frage an das Mitglied, nicht an den Server. Jede Teilnahme trägt ihre eigene Zeitzone; derselbe UTC-Zeitpunkt ergibt für zwei Menschen in verschiedenen Zonen verschiedene Occurrence-Schlüssel. Deshalb ist die Occurrence-Engine ein reines Modul ohne Datenbank und ohne Routen — so lässt sie sich erschöpfend testen, und genau das passiert. Check-ins sind über einen Unique-Constraint idempotent: Auf einem Handy mit schlechtem Empfang ist ein Retry der Normalfall, kein Fehler.

Autorisierung ist ein SQL-Prädikat, kein if. Objektzugriff sind vier zusammengesetzte WHERE-Klauseln; eine Zeile, die der Aufrufer nicht sehen darf, wird gar nicht erst geladen, und ein Treffer fehlt schlicht im Abfrageergebnis. Vier Rollensysteme — App, Challenge, Gruppe, Kurs — werden bewusst nie zusammengelegt, und die Tests halten fest, was ein Operator nicht darf: Admin erlaubt Moderation und Löschen, aber nicht CHALLENGE_EDIT. Den Zustand einer Challenge zu ändern ist Moderation; zu ändern, was sie sagt, ist Autorschaft.

Was sich herleiten lässt, wird nicht gespeichert. Streaks werden berechnet, indem die erwarteten Occurrences rückwärts durchlaufen werden; im ganzen Code steht kein += 1. Der Rang in der Rangliste wird live gezählt, weil ein monoton steigender Zähler eine aufgegebene Challenge über eine aktive stellen würde.

Web Push ist direkt gegen RFC 8291 über RFC 8188 mit einer VAPID-Assertion nach RFC 8292 geschrieben — rund hundert Zeilen, eine Abhängigkeit statt dreier. Wichtiger als die Kryptografie war die Reihenfolge: Eine Push-Nachricht geht erst raus, wenn die Transaktion nachweislich beendet ist — eine Benachrichtigung auf einem Sperrbildschirm lässt sich nicht zurückholen.

Kein Bundler, kein Framework, kein node_modules in der Web-App. Der Service Worker cacht statische Dateien und verweigert das Cachen von HTML-Seiten: Jeder Screen berechnet seine Ringe und Zähler live, und eine gecachte Seite ist eine Seite mit selbstbewusst falschen Zahlen. Das Repository ist privat, weil ich das Produkt betreiben will; die Architektur ist dokumentiert, und ich gehe sie gern im Detail durch.