No description
  • Dart 66.7%
  • TypeScript 24.8%
  • Python 7.4%
  • HTML 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
urania 3f6149fb59 Dashboard, Pro Mente Sana, ein Icon für beides
Das Gesammelte ist für die Schweizerische Stiftung Pro Mente Sana gedacht.
Ein Tippen auf die Zahl im Kopf führt zum Dashboard: was alle zusammen und
was dieses Gerät beigetragen hat, über alle Tage. Überwiesen wird von Hand
und freiwillig -- die App hat kein Konto, keine Zahlungsfunktion und keinen
Anspruch auf das Geld.

Dafür rechnet /api/totals mit ?userId= zusätzlich den eigenen Anteil aus.

Der Kurs bekommt einen Anker statt einer Behauptung: Bewegungsmangel kostet
die Schweiz rund 1,7 Milliarden Franken im Jahr, etwa 190 pro Kopf (BAG,
2022). Fünf Rappen je hundert Schritte sind daraus gerundet, nicht
ausgerechnet -- und genau so steht es auch im Dashboard.

Store- und App-Icon zeigten bisher verschiedene Kompositionen: der Store das
unbeschnittene Bild, der Launcher das auf die Mitte begrenzte. Jetzt ist es
eine einzige, mit Rand ringsum. Strichstärke und Punktgrösse hängen am
Rasterabstand statt an der Bildgrösse, sonst verklumpt die Linie, sobald das
Raster feiner wird.

Der Server hat keine Website mehr: alles ausserhalb von /api antwortet mit
"not found".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 01:08:05 +02:00
apps Dashboard, Pro Mente Sana, ein Icon für beides 2026-08-28 01:08:05 +02:00
.gitignore Schlüssel für Schreibzugriffe, Galerie nur noch in der App 2026-08-28 00:17:11 +02:00
package-lock.json mentesana: Punktfeld, Schrittzähler, Tagesbild 2026-08-28 00:08:54 +02:00
package.json mentesana: Punktfeld, Schrittzähler, Tagesbild 2026-08-28 00:08:54 +02:00
README.md Dashboard, Pro Mente Sana, ein Icon für beides 2026-08-28 01:08:05 +02:00

mentesana

Ein Schritt ist ein Punkt. Hundert Schritte sind fünf Rappen.

Die App legt ein unendliches Punktfeld über den Tag. Jeder gegangene Schritt setzt einen Punkt und verbindet ihn mit dem vorherigen. In welche der acht Richtungen es weitergeht, sagt der Kompass; ob die Linie dabei nach links oder rechts abbiegt, sagt die Seitwärtsbewegung des Geräts. Kein GPS, keine Karte, keine Route — nur Richtung und Takt. Um Mitternacht wird aus allen Pfaden des Tages ein Bild.

Das Gesammelte ist für die Schweizerische Stiftung Pro Mente Sana gedacht. Das Dashboard zeigt, was alle zusammen und was das eigene Gerät beigetragen hat; überwiesen wird von Hand und freiwillig. Die App hat kein Konto, keine Zahlungsfunktion und keinen Anspruch auf das Geld — sie zählt, und der Rest ist eine Entscheidung.

Der Kurs ist an eine Zahl angelehnt: Bewegungsmangel kostet die Schweiz rund 1,7 Milliarden Franken im Jahr, etwa 190 pro Kopf (BAG, 2022). Fünf Rappen je hundert Schritte sind daraus gerundet, nicht ausgerechnet.

Live: https://mentesana-api-production.up.railway.app Quelltext: https://git.urania.news/urania/mentesana Installieren: F-Droid-Quelle https://urania.news/fdroid/repo hinzufügen (Fingerabdruck 621a07c8a25899e00f97e4dc07ae7cfb804503be6195a8e0401443f4774a1992)

Aufbau

mentesana/
├── apps/api/     Node + TypeScript. JSON-Datei als Datenbank. Läuft auf Railway.
└── apps/app/     Flutter. Die Android-App mit Schrittzähler und Punktfeld.

Der Server hält keine Sitzung und kennt keine Konten: eine Kennung, ein Datum, ein Pfad. Die App schickt jedes Mal ihren ganzen Tagespfad, der Server prüft, ob er den gespeicherten fortsetzt, und übernimmt ihn dann. Ein verlorenes Paket kostet deshalb nichts weiter als einen zweiten Versuch.

apps/api

Eine Datei, ein Objekt im Speicher, atomare Schreibvorgänge. Auf Railway liegt sie auf einem Volume unter /data/mentesana.json und lässt sich jederzeit mit cat anschauen — genau das war der Punkt an einer JSON-Datenbank.

Die Galerie lebt in der App. Es gibt bewusst keine Website dazu: eine zweite, halb gepflegte Ansicht derselben Bilder im Browser bräuchte niemand. Alles ausserhalb von /api antwortet mit not found.

Route Zweck
GET /api/health Lebenszeichen
GET /api/totals?userId=… Schritte und Rappen — gesamt, heute, und mit userId der eigene Anteil
POST /api/sync Tagespfad hochladen (Schlüssel nötig)
GET /api/today Der laufende Tag, alle Geher
GET /api/gallery?limit=30 Abgeschlossene Tage
GET /api/artwork/:date Ein Tag

Kunstwerke tragen keine Namen. Ein Weg heisst nach dem Tag, an dem er gegangen wurde; alles andere wäre eine Deutung, die niemand bestellt hat.

POST /api/sync ist streng, aus zwei Gründen.

Der Pfad ist ein Gang über ein Raster, also muss jeder Punkt ein Nachbar des vorherigen sein. Was diese Regel bricht, ist kaputt oder gefälscht.

Und ein gegangener Weg lässt sich nicht nachträglich ändern. Der Server nimmt nur an, was den gespeicherten Pfad fortsetzt: abgeschlossene Segmente müssen Punkt für Punkt gleich bleiben, das letzte darf wachsen, hinten dürfen neue dazukommen. Alles andere ist ein Umschreiben und wird mit 409 samt dem gespeicherten Stand abgewiesen. Das Gerät übernimmt dann diesen Stand und geht von dessen letztem Punkt aus weiter — nach einer Neuinstallation mit derselben Kennung etwa.

Gerechnet wird in Milli-Rappen (50 pro Schritt), gerundet erst auf der Summe. Bei Bruchzahlen würde sich über hunderttausende Schritte ein Fehler aufsummieren, den am Ende jemand bezahlen müsste.

Schlüssel

Schreiben braucht einen Schlüssel, Lesen nicht — die Galerie ist öffentlich. Der Server erwartet ihn als Authorization: Bearer … oder X-Mentesana-Key und vergleicht ihn in konstanter Zeit mit MENTESANA_KEY aus der Umgebung. Ist die Variable nicht gesetzt, weist er jeden Schreibzugriff ab: ein Server, der bei fehlender Konfiguration alles durchlässt, ist gefährlicher als einer, der steht.

Der Schlüssel steckt in der App und in einer Railway-Variable. Er hält fremde Schreiber ab, die die Adresse zufällig finden — mehr nicht: wer die APK entpackt, hat ihn. Gegen gefälschte Pfade schützt nicht er, sondern die Regel, dass ein gespeicherter Pfad sich nicht mehr ändern lässt.

npm install
npm test          # 24 Tests
npm run dev       # http://localhost:3000

DATA_DIR bestimmt, wo die Datenbank liegt (Vorgabe: ./data).

PURGE_USER_IDS=a,b räumt beim Start die Pfade dieser Kennungen aus jedem Tag und jedem Kunstwerk. Für Testdaten, die es in die echte Datenbank geschafft haben — danach die Variable wieder leeren. Es gibt bewusst keine Route dafür: Löschen ist Wartung und gehört an die Umgebung des Servers, nicht ans offene Netz.

apps/app

cd apps/app
flutter test
flutter run --dart-define=MENTESANA_KEY=

Ohne MENTESANA_KEY läuft die App, aber der Server nimmt nichts von ihr an; sie sagt das beim Start. Gegen einen lokalen Server: --dart-define=MENTESANA_API=http://10.0.2.2:3000 (das ist der Rechner, von dem aus der Emulator gestartet wurde).

Es gibt nichts einzustellen und kein Konto: keine Serveradresse, keinen Namen, keine Anmeldung. Ein Gerät bekommt beim ersten Start eine Kennung, und die ist alles, was einen Weg seinem Geher zuordnet. Wer die App öffnet, soll gehen können, nicht konfigurieren.

Veröffentlichen

flutter build apk --release \
  --dart-define=MENTESANA_API=https://mentesana-api-production.up.railway.app \
  --dart-define=MENTESANA_KEY="$MENTESANA_KEY"

Signiert wird mit dem Schlüsselbund, auf den android/key.properties zeigt. Die Datei steht nicht im Repo, und der Schlüsselbund gehört gesichert: ihn zu verlieren heisst, die App nie wieder aktualisieren zu können, ihn zu veröffentlichen heisst, dass jeder Aktualisierungen unterschieben kann. Fehlt die Datei, greift der Debug-Schlüssel — gut genug zum Ausprobieren, nichts zum Veröffentlichen.

Vor dem Veröffentlichen prüfen, ob der Schlüssel wirklich im Build steckt. Einmal ist er stillschweigend nicht angekommen, und die veröffentlichte Version konnte nichts senden:

unzip -p build/app/outputs/flutter-apk/app-release.apk lib/arm64-v8a/libapp.so \
  | grep -qaF -e "$MENTESANA_KEY" && echo "Schlüssel drin"

Ins F-Droid-Repo kommt die fertige APK über die Publish-API von /home/ubuntu/Code/urania:

curl -X POST "https://urania.news/api/publish?name=mentesana-1.0.4.apk" \
  -H "Authorization: Bearer $PUBLISH_TOKEN" \
  --data-binary @build/app/outputs/flutter-apk/app-release.apk

Wie gezeichnet wird

lib/src/walk/geometry.dart hält die Regel: der Kompass wählt einen von acht Sektoren, die Seitwärtsbewegung dreht die Richtung um höchstens 20° dazu. Das reicht, um nahe an einer Sektorgrenze umzukippen, und reicht nicht, um den Kompass zu überstimmen. Wer geradeaus geht, zeichnet eine Gerade; wer immer wieder nach rechts ausweicht, zeichnet eine Kurve.

Eine Pause — Dienst gestoppt, Gerät neu gestartet, Mitternacht — beendet das aktuelle Segment und beginnt ein neues am selben Punkt. Im Bild entsteht so eine ehrliche Lücke statt einer erfundenen Verbindung. Von Hand trennen lässt sich die Linie nicht: die Lücken sollen die echten Unterbrechungen sein, keine gestalterischen. Ohne Kompasssignal wird gar nicht gezeichnet.

Alle Linien sind grün. Der eigene Weg leuchtet, die der anderen liegen blass darunter: das Bild eines Tages ist ein gemeinsames, kein Wettbewerb um die auffälligste Spur.

Warum zwei Schrittzähler

Der Zähler des Betriebssystems ist genau, aber langsam — Android bündelt seine Meldungen und liefert sie mit mehreren Sekunden Verzug. Zum Zählen reicht das, zum Zusehen nicht.

Deshalb erkennt StepDetector den Schritt sofort aus der Beschleunigung und zeichnet ihn, und StepReconciler hält beide Zahlen zusammen: meldet die Hardware mehr Schritte als gezeichnet wurden, werden sie nachgetragen; meldet sie weniger, wird ausgesetzt, bis die Rechnung wieder aufgeht. Ein gezeichneter Punkt lässt sich nicht zurücknehmen, also ist Aussetzen die einzige Korrektur nach unten. Über die Zeit stimmt das Bild damit genau mit dem Betriebssystem überein — und niemand spendet dafür, dass er sein Telefon geschüttelt hat.

Bilder

tool/make_icons.py erzeugt das App-Icon, das Kopfbild fürs Repo und das Sternbild der Quelle — alles aus demselben Algorithmus, den auch die App zeichnet, nur mit festem Zufallsgenerator. Das Icon liegt als klassisches und als adaptives Icon vor: ab Android 8 legt der Launcher seine eigene Form darüber, deshalb darf das Feld bis an den Rand laufen und beschnitten werden, während die Linie in der Mitte bleibt.

tool/screenshots_test.dart rendert die Bildschirmfotos fürs Repo, ohne Emulator: Flutters Golden-Mechanismus zeichnet dieselben Widgets, die auf dem Gerät laufen, in eine PNG-Datei.

python3 tool/make_icons.py
flutter test tool/screenshots_test.dart --update-goldens

Beides landet unter tool/art/ und tool/screenshots/ und wird von Hand ins Repo geschoben — die Publish-API nimmt neben APKs auch Metadaten und Grafiken:

curl -X POST "https://urania.news/api/metadata?package=news.urania.mentesana" \
  -H "Authorization: Bearer $PUBLISH_TOKEN" --data-binary @metadata.yml
curl -X POST "https://urania.news/api/graphic?package=news.urania.mentesana&kind=icon" \
  -H "Authorization: Bearer $PUBLISH_TOKEN" --data-binary @tool/art/icon-512.png

Hintergrundbetrieb

Massgeblich gezählt wird mit dem Schrittzähler des Betriebssystems (ACTIVITY_RECOGNITION). Damit die App weiterzeichnet, wenn der Bildschirm aus ist, läuft die gesamte Logik in einem Vordergrunddienst (flutter_foreground_task, foregroundServiceType="health") in einem eigenen Isolate. Android darf die Oberfläche jederzeit abräumen, den Dienst nicht, solange seine Benachrichtigung steht. Die Oberfläche ist damit ein Fenster auf diesen Zustand und nicht seine Quelle: sie bekommt die neuen Punkte über den Port zugeschickt und holt sich den ganzen Pfad neu, sobald die Schrittzahlen auseinanderlaufen.

Wird der Dienst doch einmal beendet, geht nichts verloren: der Schrittzähler des Geräts zählt seit dem letzten Neustart weiter, und beim nächsten Start trägt CumulativeStepCounter nach, was in der Zwischenzeit gegangen wurde.

Beim ersten Start fragt die App nach Bewegungsdaten, Benachrichtigungen und einer Ausnahme von der Akku-Optimierung. Ohne die letzte legt Android den Dienst nach einiger Zeit schlafen.

Was nicht geht: iOS erlaubt keinen dauerhaften Hintergrunddienst dieser Art. Dort zählt zwar CMPedometer im Hintergrund weiter und wird beim Öffnen der App nachgetragen, aber ohne Kompasswerte aus der Zwischenzeit landen diese Schritte als gerade Linie im Bild. Die App ist auf Android gebaut.

Railway

Das Projekt heisst mentesana, der Service mentesana-api, das Volume hängt unter /data. Deployt wird aus apps/api/ heraus:

cd apps/api && railway up --service mentesana-api

Nixpacks führt beim Bauen npm run build und npm run test aus — ein Deploy, das die eigene Prüfung nicht besteht, startet gar nicht erst.

Railway kündigt railway.json zum 1. Dezember 2026 ab; wer vorher umstellt, führt railway config migrate --apply aus und benennt den Service in der erzeugten .railway/railway.ts von api auf mentesana-api um.