No description
  • JavaScript 50%
  • HTML 39.5%
  • Kotlin 10.5%
Find a file
2026-10-25 13:02:14 +01:00
app Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
gradle Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
tests Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
.gitignore Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
build.gradle.kts Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
COPYRIGHT Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
gradle.properties Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
gradlew Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
gradlew.bat Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
LICENSE Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
README.md Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
settings.gradle.kts Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
sync-paired-dark.png Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00
sync-unpaired-light.png Stand vom 2026-10-25 2026-10-25 13:02:14 +01:00

Finanzen

(hieß bis v2.21.1 „Finanzplan" — umbenannt, weil längst mehr als Planung darin steckt. Package, Verzeichnis- und Repo-Name bleiben finanzplan: ein anderes Package wäre im F-Droid-Repo eine neue App.)

Offline-Android-App für anstehende Ausgaben und erwartete Einnahmen: Prioritäten (nötig/optional), Fälligkeiten, laufende Fixkosten, Bilanz-Übersicht, Backup-Export/-Import, Partner-Sync direkt im WLAN. Keine externen Assets, kein Server: INTERNET wird ausschließlich für den verschlüsselten Direkt-Abgleich zwischen zwei Telefonen im lokalen Netz genutzt, CAMERA nur fürs Scannen des Kopplungs-QR-Codes (Details: PLAN-sync.md).

Aktuell: v2.22.0 (versionCode 44)

Architektur

Native WebView-Hülle (Kotlin) + eine einzige Datei mit der gesamten App:

app/src/main/assets/app.html   ← komplette UI + Logik (HTML/CSS/JS, kein Framework)
app/src/main/java/…            ← dünne Kotlin-Shell (WebView, JS-Bridge „Android")

Die JS-Bridge (window.Android) liefert: load()/save(json) (Persistenz), exportData()/importData() (Backup als Datei), toastMsg(), isDarkMode(), fürs Partner-Sync syncIsPaired(), syncPairShow(), syncPairScan(), syncOffer(), syncConnect(), syncUnpair() (Implementierung: SyncManager.kt), sowie für den Tresor vaultState(), vaultUnlock(pin), vaultSetPin(pin), vaultAutoLock()/vaultSetAutoLock(min), vaultChangePin(alt,neu), vaultRecover(json,pin) (Implementierung: Vault.kt). vaultDisable(pin) gibt es nativ weiterhin, seit der PIN-Pflicht ruft es aber niemand mehr aus der Oberfläche.

Fensterschale: Tastatur und Systemleisten (v2.6.3)

Seit targetSdk 35 (Android 15) zeichnet die App zwangsweise edge-to-edge unter Status- und Navigationsleiste, und android:windowSoftInputMode="adjustResize" verkleinert das Fenster nicht mehr, wenn die Tastatur kommt. MainActivity.bauLayout() regelt das deshalb selbst:

  • WebView steckt in einem FrameLayout, WindowCompat.setDecorFitsSystemWindows(window, false) — damit gilt auf allen Android-Versionen derselbe Weg.
  • Der Insets-Listener auf der Wurzel setzt systemBars() und ime() als Ränder (layoutParams.topMargin usw., unten maxOf(Tastatur, Navigationsleiste)) auf die WebView und gibt WindowInsetsCompat.CONSUMED zurück.
  • Darüber liegt ein View-Streifen in Markenfarbe mit Höhe = oberer Inset, sonst wirkt die Statusleiste abgeschnitten.

Wichtig, nicht zurückdrehen: Der Abstand muss über die Ränder laufen. Padding auf die WebView (so der erste Anlauf in v2.6.1) beschneidet den Inhalt nur — die Seite bricht nicht neu um, 100vh/position:fixed/scrollbare Blätter bleiben hinter der Tastatur und das Eingabefeld ist dann gar nicht mehr erreichbar.

Die Seite zieht mit: unter der Bridge setzt sie --safe-t/--safe-b auf 0 (sonst zählt der Abstand doppelt), rückt bei focusin das beschriebene Feld per scrollIntoView in den Blick und legt body.gesperrt (position:fixed mit gemerkter Scrollposition) an, solange ein Blatt offen ist — sonst scrollt die Seite unter dem offenen Menü weg.

Datenmodell (Schema v12)

{ "version": 12, "items": [], "recurring": [], "spent": [], "income": [], "budgets": [], "carry": [], "monthStart": [], "catcolors": [], "deleted": [] }
  • items[] — Geplantes (einmalige Ausgaben/Einnahmen): kind (aus/ein), title, amountCents (Integer-Cents!), priority (noetig/optional, nur Ausgaben), category, dueDate, note, done/doneAt, createdAt, updatedAt.
  • recurring[] — Laufendes (Fixkosten/-einnahmen): cadence (monat/quartal/halbjahr/jahr), anchorDay/anchorMonth, startDate/endDate, active, updatedAt — und seit v11 seriesId (Klammer über die Zeitscheiben eines Postens, leer = alleinstehend, siehe „Fixkosten mit Verlauf"). monthlyEquiv(r) rechnet auf Ø/Monat um (Jahr÷12, Halbjahr÷6, Quartal÷3) — für einen Zeitraum wird dagegen gezählt, nicht hochgerechnet (recDueIn).
  • spent[] — Ausgegeben-Logbuch (v2.2.0): tatsächlich getätigte Ausgaben als Rückschau, bewusst getrennt von den Plan-Posten: title, amountCents, date (ISO-Tag, vorbelegt heute), tag (frei, gemeinsamer Vorschlags-Topf mit den Kategorien), note, createdAt, updatedAt. Ansicht: Monatsgruppen mit Summe
    • Tag-Zusammenfassung je Monat; Kopf zeigt aktuellen Monat und Vormonat.
  • income[] — Einnahmen-Logbuch (v2.12.0): tatsächlich erhaltenes Geld (Rückerstattung, Geschenk, Nebenverdienst) mit denselben Feldern wie spent[], aber ohne budgetId — Budgets sind Ausgabentöpfe. Eigene Liste statt eines kind-Feldes in spent[]: ein Partner auf ≤2.11 kennt normalizeSpent ohne kind, würde den Eintrag beim Sync als Ausgabe zurückschicken und das Vorzeichen dauerhaft verlieren; eine unbekannte Liste schickt er dagegen gar nicht erst mit (derselbe Schutz wie bei carry[]). Dargestellt in derselben Monatsliste wie die Ausgaben (grüne Karte, +), im Monatssaldo als Zeile „Erhalten", im Kopf als „Erhalten · Netto". Zählt nicht in die Kategorie-Balken der Ausgaben. Seit v2.13.0 tragen Ausgaben spiegelbildlich ein − — aber in normaler Schriftfarbe, nicht rot: Rot ist in der App das Signal für „überfällig" und für ein negatives Ergebnis; auf 95 % aller Karten verliert es jede Warnwirkung. Grün funktioniert beim + gerade deshalb, weil Einnahmen die Ausnahme sind.
  • budgets[] — Budgets (v2.7.0, PLAN-budgets.md): zweckgebundene Töpfe („Urlaub", „Renovierung"): name, targetCents, dueDate (optional), color, note, archived, updatedAt. Gespeist rein durch Abziehen: spent-Einträge tragen optional budgetId; Rest = Ziel − Summe der zugeordneten Beträge. Vierter Bereich im Segment-Band „Geplant · Laufend · Ausgegeben · Budgets" (v2.8.0; vorher eigene Vollbild-Ansicht). v7-Felder (PLAN-budgets-v2.md):
    • category — Kategorie des Topfs; beim Zuordnen einer Ausgabe wird das Tag-Feld sichtbar damit vorbelegt (bleibt editierbar), die Auswertung bleibt tag-basiert.
    • listMode (einzeln|summe|aus) — wie die zugeordneten Ausgaben in „Ausgegeben" erscheinen: einzeln, als eine Sammelkarte je Monat, oder gar nicht.
    • countInTotals (Standard true) — zählen die zugeordneten Ausgaben in Monats-/Jahressummen, Jahresbalken und Kategorie-Aufschlüsselung mit? Aus → Topf läuft getrennt; der Kopf weist ausgenommene Beträge sichtbar aus.
  • carry[] — Übertrag/Startwert je Monat (v2.11.0): {id: "carry-YYYY-MM", ym, amountCents (darf negativ sein), note, createdAt, updatedAt}, höchstens ein Satz je Monat. Die id ist bewusst aus dem Monat abgeleitet, damit zwei Geräte beim Sync denselben Satz treffen und der übliche LWW-Merge greift. Ein Übertrag ist kein Posten und keine Kategorie: er hat keine Fälligkeit, wird nicht abgehakt und taucht in keiner Kategorie-Auswertung auf.
  • monthStart[] — Monatsbeginn je Monat (v2.23.0): {id: "start-YYYY-MM", ym, date (ISO), createdAt, updatedAt}, höchstens ein Satz je Monat, id wie bei carry[] aus dem Monat abgeleitet. Der Satz sagt: dieser Monat beginnt an diesem Tag — weil das Gehalt dann kam. Siehe „Monatsbeginn" unten.
  • catcolors[] — Farbe je oberster Kategorie (v2.13.0): {id: "cat:Name", name, color (hex-validiert), createdAt, updatedAt}. Die id ist wie bei carry[] aus dem Namen abgeleitet, damit zwei Geräte denselben Satz treffen. Ohne Eintrag würfelt catColorBase() weiter aus dem Namens-Hash — die Liste ist also reine Übersteuerung, kein Pflichtstammsatz.
  • Nicht im Datenstand: die Farbe der „nötig"-Streifen (v2.14.0). Sie ist reine Ansichtssache und liegt wie Erscheinungsbild und Dichte in localStorage (fp_noetig, Vorgabe Orange #D97706) — CSS-Variable --noetig, im Dunkeln von applyNoetig() aufgehellt. Früher war das --accent (Türkis) und lag damit zu dicht an Grün (Einnahmen).
  • deleted[] — Tombstones {id, type: item|rec|spent|income|budget|carry|catcolor, deletedAt} für den Sync-Merge (damit Löschungen nicht „reanimiert" werden); Pruning nach 180 Tagen.

Kategorien sind Pfade (v2.13.0)

category/tag bleiben ein String; / trennt die Ebenen: Freizeit/Konzert/Oper. Bewusst kein Tag-Baum und keine Mehrfach-Tags:

  • Jede vorhandene Kategorie ist bereits ein gültiger Pfad der Ebene 1 — keine Migration, kein Sonderfall im Sync-Merge, keine Grenze in der Tiefe.
  • Ein Filter auf einen Pfad zählt alle Unterebenen mit (pathUnder), damit die Frage „was habe ich dieses Jahr für Konzerte ausgegeben" eine Antwort hat, ohne jede Unterebene einzeln anzuklicken.
  • Die Balken zeigen immer eine Ebene: gruppiert wird auf pathCut(cat, prefix), ein Tipp filtert auf den Pfad und öffnet die Ebene darunter (catDrillPrefix, Pfad-Leiste catCrumbHtml). Bei Mehrfachwahl bleibt die gemeinsame Elternebene stehen, sonst ließen sich die Nachbarn nicht mehr abwählen.
  • Pillen tragen seit v2.16.0 den Pfad (pathShort): bis drei Ebenen und ~28 Zeichen ausgeschrieben, sonst in der Mitte gerafft (Urlaub/…/Taxi) — erste und letzte Ebene bleiben immer sichtbar. Die Ebenen davor stehen leiser (.cc-pre). Vorher stand dort nur die letzte Ebene mit einem ‹; den vollen Pfad im title bekommt man am Handy nie zu sehen.
  • Verworfen: echter Tag-Baum (≈4× Aufwand, kann dasselbe) und mehrere flache Tags je Eintrag (Summen doppeln sich, keine Reihenfolge).

Ein Name lebt an genau einer Stelle (v2.14.0). Wer „Musik/Album" tippt, obwohl „Musik" schon unter „Freizeit" hängt, bekommt „Freizeit/Musik/Album" — anchorPath() hängt den getippten Pfad an dem Ort ein, den der Baum für seine erste Ebene schon kennt, und die App sagt es per Toast. Nur bei einem bekannten Ort und nie, wenn der getippte Pfad schon genau so existiert. Der Grund: ohne das stünde „Musik" zweimal im Baum und eine Auswertung auf „Musik" fände nur die Hälfte. Für Altbestand zeigt das Kategorien-Blatt einen Hinweis mit Knopf „Einordnen" (catDoubles()/mergeCatDouble()).

Seit v2.16.0 zwei Nachschärfungen: Gibt es mehrere mögliche Orte („Mobilität" wohnt unter Urlaub und unter Arbeit), wird gefragt statt durchgewunken (anchorOptions() → askPick(); Abbrechen speichert nicht, deshalb sind saveEditor/saveRec/saveSpent/saveBudget async). Und der Doppel-Hinweis meldet nur noch den Fall, der wirklich schadet: derselbe Name steht lose ganz oben und zusätzlich eingehängt (Wein neben Haushalt/Einkauf/Wein). Zwei eingehängte Orte sind gewollt und bleiben still; Ziel des Einordnens ist der tiefere Ort, nicht mehr der meistgenutzte. Bewusst nicht namensbasiert aggregiert (also „alles, wo irgendwo Musik vorkommt"): dann überlappten sich die Summen und die Balken addierten sich nicht mehr zum Monatswert — genau die Eigenschaft, für die es die Übersicht gibt.

Die gewählte Ebene steht über ihren Unterebenen (v2.14.0): beim Aufklappen kommt zuerst die Kategorie selbst mit ihren direkten Posten (Zusatz „direkt"), darunter eingerückt und mit Führungslinie ihre Unterebenen. Vorher sortierte nur der Betrag, sodass das Kind über dem Elternteil stehen konnte.

Vorschläge führen eine Ebene tiefer (v2.14.0): ein angetippter Vorschlag schließt die Liste nicht mehr, sondern zeigt „weiter mit …" und die direkten Unterebenen. Ohne das musste man wissen, dass man „/" tippen muss, um überhaupt tiefer zu kommen.

Die Farbe gehört der obersten Ebene, Unterebenen erben sie je Stufe eine Spur heller (lighten, in JS statt color-mix() — das kennen ältere Android-WebViews noch nicht). Schrift auf farbigem Grund schaltet über readableOn() zwischen Weiß und Dunkel, sonst würde eine aufgehellte oder frei gewählte Farbe unlesbar. Menü → Kategorien listet alle Ebenen mit Nutzungszahl, lässt die Farbe wählen (Automatisch löscht den Eintrag wieder) und benennt einen ganzen Zweig um — renameCategoryPath() läuft dafür über items/recurring/spent/income/budgets, weil Kategorien kein eigenes Objekt sind, und zieht den Farbeintrag mit um.

Suche im Logbuch: der Weg entscheidet über den Filter (v2.16.0)

Zwei Fragen an dieselben Daten, zwei Wege — und jede Antwort steht für sich:

  • Über die Balken (Ausgegeben → Monat) sieht man Freizeit/Musik mit allem darunter. Aufteilung, Aufklappen und Summen bleiben unverändert (catBars, inCatFilter, catDrillPrefix): jeder Euro zählt genau einmal.
  • Über das Suchfeld sieht man */Musik/* — dieselbe Ebene, egal unter welcher Oberkategorie sie hängt (state.logQuery, hasSeg()).

Verglichen werden ganze Ebenen, nicht Textstücke: „Wein" findet Haushalt/Einkauf/Wein, aber nicht Haushalt/Einkauf/Weinregal (eine Textstück-Suche träfe bei „Ein" sonst Einkauf, Wein und Einnahmen zugleich). Für die Vorschläge unter dem Feld reicht dagegen ein Textstück. Gesucht wird erst beim Tippen auf einen Vorschlag oder mit Enter (applyQuery) — würde jeder Tastendruck neu rendern, verlöre das Feld unter der Hand den Fokus.

Ein Feld, mehrere Bereiche (v2.17.0). segSearchHtml(scope) bedient Ausgegeben, Fixkosten und Gesamtrückblick; jeder Bereich hat seinen eigenen Zustand (state.logQuery/fixQuery/rueckQuery) und vor allem seinen eigenen Bestand (searchEntries(scope) → {cat, cents, plus?}):

  • allSegments(entries) speist die Vorschläge nur aus den Pfaden, die im durchsuchten Bestand wirklich vorkommen. Vorher kam die Grundmenge aus dem gesamten Kategorienbaum — im Log wurden dadurch Ebenen vorgeschlagen, die dort nichts finden können („Kommunikation · an 3 Stellen" → 0,00 €).
  • Statt der Herkunft steht am Vorschlag jetzt die Antwort: segStats(seg) nimmt die Suche vorweg („Hardware · 289,00 € · 4 Posten · 3 Orte"). Vor dem Antippen ist damit sichtbar, ob sich der Treffer lohnt.
  • Doppelt zählen kann das nicht: ein Posten hat genau einen Pfad und wird je Ebene genau einmal gezählt (Entdopplung innerhalb eines Eintrags). Einnahmen zählen als Posten mit, aber nicht in die Ausgaben-Summe (plus).
  • Im Trefferband schlüsselt queryHomesHtml die Fundorte mit ihren Teilsummen auf (pathUpTo) — anders als die Suche selbst ist das wieder eine echte Partition und addiert sich exakt auf die Suchsumme. Erst ab zwei Fundorten sichtbar.

Weil die Suche damit sauber über mehrere Stellen summiert, ist die catDoubles()-Ermahnung im Kategorien-Blatt ersatzlos entfallen (v2.17.0): sie war eine Krücke gegen ein Problem, das die Suche besser löst. Umbenennen und Umhängen bleiben als Werkzeug, anchorPath() als Tipp-Bequemlichkeit — aber ohne Ermahnungscharakter. Abgrenzung: für die Aufschlüsselungs-Balken gilt weiter das Gegenteil; die müssen partitionieren und bleiben pfadbasiert.

Wirkung: die Postenliste wird auf die Treffer eingedampft, darüber steht ein Band („Wein · 62,40 € · 4 Posten · ✕"). Die Balken bleiben stehen, sonst spränge die Navigation weg, aus der man kam. Suche und Balkenfilter gelten zusammen (UND). Budget-Sammelkarten (listMode) lösen sich bei aktiver Suche in Einzelposten auf (buildLogUnits(..., forceSingles)) — sonst suchte man in eine Sammelkarte hinein und fände nichts. Ausgaben und Einnahmen zählen beide.

In der Jahresansicht lassen sich Monate markieren (state.logMonths, leer = ganzes Jahr): der Kasten links in der Zeile markiert, das Tippen auf die Zeile springt weiter in den Monat — zwei Ziele in einer Zeile, kein Moduswechsel. Die Markierung wirkt auf Aufschlüsselung, Suche und Trefferliste. Eine Postenliste führt die Jahresansicht nur bei aktiver Suche; sonst wären es hunderte Karten.

Verworfen: (1) Balken auf „Pfad enthält Segment" umzustellen — dann spränge die Summe beim Aufklappen über den Balken hinaus, aus dem man kam. (2) Eine Liste „Schlagworte" mit allen Tags nebeneinander — dort stünden dieselben 50 € in der Konzert-, der Musik- und der Freizeit-Zeile. Ein Suchfeld gibt immer eine Antwort auf eine Frage.

Alle Zustände (catFilter, logQuery, fixQuery, rueckQuery, logMonths, seit v2.26.0 auch rueck.bucket) sind reine Ansicht, nicht persistiert, nicht im fullPayload und damit kein Sync-Thema; clearFilters() räumt sie gemeinsam ab, sobald Ansicht, Monat oder Bereich wechselt.

Fixkosten mit Verlauf: Zeitscheiben (v2.17.0)

Anlass: Mieterhöhung. Wird der Betrag einfach überschrieben, rechnet jede Rückschau das ganze Jahr mit dem neuen Wert — bei 40 € Unterschied über sieben Monate ein Fehler von 280 €, und die Miete ist der größte Posten.

Eine Änderung ist deshalb kein neuer Betrag, sondern ein zweiter Zeitraum: startDate/endDate grenzen die Scheiben ab (das konnte recInMonth immer schon korrekt), seriesId klammert sie zusammen. Als gewöhnliches Feld unter LWW braucht das keinen Sonderfall im Sync-Merge und keinen neuen Tombstone-Typ; ein Partner auf v10 sieht schlicht zwei alleinstehende Posten.

Bedienregel: man gibt nur „ab wann" an, nie „bis wann." Jedes endDate leitet resealSeries() ab (Tag vor dem Beginn der nächsten Scheibe); die jüngste bleibt offen oder behält ein selbst gesetztes Ende — ein gekündigter Vertrag ist kein Rechenfehler. Lücken und Überlappungen sind damit nicht konstruierbar. Ein Datum mitten im Verlauf schiebt sich sauber dazwischen, weil resealSeries alle Grenzen neu zieht. Löschen einer Scheibe schließt die Lücke (closeSliceGap); bleibt eine übrig, fällt die Klammer weg.

  • Liste: eine Karte je Serie, gezeigt wird die heute gültige Scheibe (fixCards, activeSlice) — dauerhaft eine „Miete", nicht fünf. Badge „Verlauf · N" öffnet das Blatt, Badges nennen jetzt auch „ab …".
  • Blatt: Scheiben von heute nach hinten, „aktuell" markiert, je Zeile Betrag + Zeitraum; darunter „Früheren Betrag ergänzen" und „Betrag ändern ab …".
  • Einstieg (v2.17.1, ausgebaut in v2.17.3): im Editor jedes Fixpostens steht unter dem Betrag „Betrag hat sich geändert …" (recHistRow, histFromEditor) — bei einer Serie stattdessen „Verlauf öffnen · N Zeiträume". Ohne diese Zeile war das Blatt beim ersten Mal unerreichbar: das Badge „Verlauf · N" gibt es erst ab zwei Scheiben, und der erste Wechsel ist genau der, den man eintragen will. histFromEditor speichert das offene Blatt zuerst (saveRec meldet jetzt Erfolg zurück), sonst gingen die Eingaben beim Wechsel verloren. Ein Posten ohne Startdatum ist dabei kein Sonderfall mehr: „von Anfang an" endet am Tag vor der neuen Scheibe, resealSeries erledigt das — die frühere Abweisung („nutze Früheren Betrag ergänzen") gilt nur noch, wenn hinter einem beginnlosen Anfang schon Scheiben stehen. v2.17.3: der Knopf sprang beim einzelnen Posten direkt in „Betrag ändern ab …" und kannte damit nur eine Richtung. Wer den heutigen Betrag eingetragen hat und nachträgt, was vorher galt (Beitrag zum 1.3. erhöht), stand vor einer Sackgasse: „Früheren Betrag ergänzen" erschien erst im Blatt, das Blatt erst ab zwei Scheiben. Jetzt führt der Knopf immer ins Blatt, das schon bei einer Scheibe beide Richtungen anbietet.
  • Migrationsweg für einen Posten ohne Zeiträume: „Früheren Betrag ergänzen" fragt Betrag + „ab wann" und — weil der bisherige Betrag sonst weiter ab Anfang gälte und beide in denselben Monaten zählten — zusätzlich, ab wann dieser gilt. Von Hand nachgebaut ginge das schief. Das „ab wann" des früheren Betrags darf seit v2.17.3 leer bleiben („gilt von Anfang an") — wer nur den letzten Wechsel kennt, müsste sonst einen Beginn erfinden.
  • Serie vs. Scheibe: Bezeichnung, Art, Rhythmus, Kategorie und Notiz gehören der Serie (propagateSeries), nur Betrag und Datumsgrenzen der Scheibe. Bei älteren Scheiben ist „Läuft bis" im Editor darum ausgeblendet.
  • Beendetes (jüngste Scheibe abgelaufen, kein Nachfolger) wandert in den eingeklappten Abschnitt „Früher" am Listenende — es zählt weiter korrekt in jede Rückschau, steht aber nicht mehr im Blick.
  • Überlappungen aus Altbestand oder Sync-Kollision werden gemeldet (seriesOverlap, Badge + Hinweis im Blatt), aber nicht blockiert.
  • Ø-Rechnungen sehen je Serie nur eine Scheibe (v2.17.2). Wer über state.recurring summiert, zählt eine Mieterhöhung mit altem und neuem Betrag (850 + 890 = 1.740 €/Monat) und schleppt gekündigte Verträge weiter mit, obwohl sie in der Liste schon unter „Früher" stehen. Alle Ø-Sichten gehen darum über currentRecs()/recEquivSum() (je Serie die gültige Scheibe, nichts Abgelaufenes): Fixkosten-Kopf „Ø pro Monat (fix)", der Fixkosten-Block der Übersicht und das Puffer-Band. Ebenso searchEntries('fix') — der Vorschlag versprach sonst „Wohnen 1.740,00 € · 2 Posten", das Trefferband zeigte danach 850,00 €. Merke: seit den Zeitscheiben ist state.recurring keine Liste von Posten mehr, sondern eine Liste von Zeiträumen. Die Monatssichten waren nie betroffen, sie rechnen über recInMonth datumsgenau.

Verworfen: Overrides je Monat. Dort ist der Grundbetrag immer der aktuelle, die Vergangenheit wird zur Ausnahmeliste — und bei jeder Änderung kehrt sich das um.

Laufend → Jahr (v2.17.0)

Dritter Reiter neben Fixkosten und Monat: „Was kostet mich der Telefonvertrag im Jahr?" — vorher gab es nur Ø/Monat und die Monatsansicht, ein Jahresposten war als solcher gar nicht zu sehen. Gerechnet wird mit recDueIn(r, von, bis), dem gemeinsamen Rechenkern: gezählte Fälligkeiten statt Durchschnitt (12 beim monatlichen, 4 beim Quartals-, 1 beim Jahresposten). recGroupsIn fasst je Serie zusammen und zeigt die Scheiben nebeneinander („12 × 29,90 € · 358,80 €", „7 × 850,00 € · 5 × 890,00 €").

Monatssichten: was gehört wohin (v2.10.0, Übertrag ab v2.11.0)

Zwei monatsscharfe Sichten, klar getrennte Aufgaben:

  • Laufend → Monat = reiner Fixkosten-Cashflow dieses Monats (nur recurring[]). Einmalige Plan-Posten liefen hier früher mit — fachlich falsch, der Reiter steht für Wiederkehrendes.
  • Ausgegeben → Monat = der Monatssaldo (monatSaldo(y,m0), Komposition aus monatCalc + spentMonthSum + incomeMonthSum + carryFor). Zwei Zahlen, immer beide sichtbar: ohne Projektion = Übertrag + regelm. Einkommen − Fixkosten − tatsächlich Ausgegeben + tatsächlich Erhalten; mit Projektion = zusätzlich die noch offenen, in diesem Monat fälligen geplanten Posten (Liste „Noch offen für " darunter). Ist für den Monat nichts mehr offen, entfällt die Projektionszeile — dann steht schlicht „Bleibt übrig" da, und der Hinweistext sagt, warum (und wie viele geplante Posten ohne Fälligkeitsdatum in gar keinem Monat auftauchen).

Der Übertrag ist die erste Zeile der Karte: antippbar, mit Vorschlag aus dem Vormonat (monatSaldo(Vormonat).ohneProj), der aber nie automatisch gesetzt wird — die App kennt nur die erfassten Ausgaben, ein gerechneter Übertrag wäre systematisch zu optimistisch. Vorzeichen kommt über die Art-Auswahl (Überschuss/Minus) statt über ein Minuszeichen im Betragsfeld. In Laufend → Monat taucht er bewusst nicht auf: das ist reiner Fixkosten-Cashflow.

Damit ein erledigter Plan-Posten nicht doppelt zählt (offen geplant und von Hand gebucht), fragt das Abhaken einmal nach und legt den Logbuch-Eintrag selbst an (Titel/Betrag/Kategorie), mit Undo-Band — bei einer Ausgabe in spent[], bei einer Einnahme in income[] (v2.12.0). Datum = die Fälligkeit, solange sie nicht in der Zukunft liegt, sonst heute: eine Rückerstattung vom 14. gehört in ihren Monat, nicht in den Tag des Abhakens. Das Entfernen des Häkchens fragt nicht (und löscht nichts — das macht man im Logbuch).

Puffer-Band und Übersicht-Bilanz bleiben davon unberührt: sie sind die zeitlose Durchschnitts-/„alles Offene"-Sicht, kein Kalendermonat.

Geplant → Übersicht: Balken aufdröseln (v2.18.0)

Die Kategorie-Balken der Übersicht waren die letzte Aufschlüsselung ohne Tipp-Ziel (catBreakdown(..., clickable) gab bis v2.17 immer false weiter). Der Grund von damals — „hat keine Liste darunter" — gilt nicht mehr, seit Kategorien Pfade sind: ein Tipp geht eine Ebene tiefer, und die Posten der gewählten Kategorie stehen seitdem als Liste unter dem Filterband (filterBarHtml + cardHtml, dieselben Bausteine wie in Laufend → Monat/Jahr). Von dort führt eine Karte direkt in den Editor — der Weg „warum ist die Summe so hoch → welcher Posten ist das" ist damit in allen Auswertungen derselbe.

Zwei bewusste Festlegungen:

  • Nur die Ausgaben-Balken sind antippbar. Beide Karten teilten sich sonst state.catFilter, und eine Kategorie kommt selten in beiden Töpfen vor.
  • Das Filterband steht direkt unter seinen Balken, die Einnahmen-Karte rutscht dafür ans Ende der Seite — sonst läge eine fremde Karte zwischen Balken und Chip.

Der Filter respektiert „Optionale ausblenden" (gefiltert wird die schon gefilterte Liste) und fällt bei jedem Reiter-/Bereichswechsel weg (clearFilters).

Monatsbeginn — der Monat läuft von Gehalt zu Gehalt (v2.23.0)

Das Gehalt kommt am 26., am 28. oder am 29. — der Kalender kennt diesen Takt nicht. Wer am 30. etwas ausgibt, zahlt längst vom neuen Geld; im Kalendermonat landete die Ausgabe trotzdem beim alten. Geradebiegen ließ sich das vorher nur mit umdatierten Ausgaben (echtes Datum futsch) oder nachgerechnetem Übertrag.

Ein Fixposten trägt die Markierung startsMonth (das Gehalt, nur bei monatlichem Rhythmus wählbar, immer nur an einem Posten — klaereStartsMonth räumt die alte ab, propagateSeries gibt sie an alle Zeitscheiben weiter). Je Monat merkt sich die App den tatsächlichen Tag in monthStart[].

Daraus leitet sich alles ab:

  • monatsStartISO(y, m0) — gesetzt > geschätzt > der Erste. Geschätzt wird über den anchorDay des markierten Postens: liegt er hinter START_GRENZTAG (15), startet er den Folgemonat (Tag 28 → der August beginnt am 28.07.), sonst den eigenen.
  • fiskalMonat(y, m0) — der Monat als Zeitraum, bis = Tag vor dem nächsten Beginn. fiskalJahr(y) genauso, sonst wäre die Summe der zwölf Monate nicht das Jahr.
  • fiskalMonatVonISO(iso) — zu welchem Fiskalmonat gehört ein Datum? Für Gruppierungen (Zeitstrahl) und für den Grenzgänger-Chip; Summen filtern über das Fenster, das ist billiger.

Seit v2.25.0 gilt dieses Fenster nicht mehr für alles: die rückschauenden Auswertungen sind kalendarisch. Siehe „Zwei Monatsbegriffe" direkt darunter. (Die früheren Durchreicher monthRange/yearRange gibt es nicht mehr — sie klangen nach Kalender und lieferten Fiskalfenster.)

Der markierte Posten liegt auf dem tatsächlichen Zahltag, nicht auf dem erwarteten (recInMonth zieht sein iso auf monatsStartISO des Monats, den er eröffnet). Ohne das fiele das Gehalt aus „seinem" Monat heraus, sobald es einen Tag später kam als erwartet — Monat beginnt am 29., Posten steht auf dem 28., Ergebnis „Regelm. Einkommen 0,00 €". Eindeutig bleibt es, weil jedes Fenster genau einen Monatsbeginn enthält. recDueIn nimmt dafür je einen Kalendermonat Anlauf und Nachlauf, weil die Verschiebung über die Kalendergrenze rutschen kann.

Bedient wird es an drei Stellen, alle rückwirkend für jeden Monat: startBandHtml (Erinnerungsband, nur im laufenden Monat und nur solange der Tag nicht bestätigt ist), die Zeile „Zeitraum" in der Monatssaldo-Karte, und über die Monatsnavigation. Das Blatt sagt vorher an, wie viele erfasste Buchungen den Monat wechseln, warnt bei einem Tag außerhalb der Monatsmitten und bietet an, den Übertrag gleich mitzunehmen — der ist per Definition das, was bis zum Gehaltseingang übrig war, und der Tag dafür steht hier gerade fest.

Ohne markierten Posten und ohne gesetzten Satz kommt exakt der alte Kalendermonat heraus; die Umstellung ist für alle anderen Bestände wirkungslos.

Zwei Monatsbegriffe (v2.25.0)

Alles Rechnende ist fiskalisch. Alles Rückschauende ist kalendarisch.

v2.23.0 zog alle Monats- und Jahresauswertungen auf das Gehalt-zu-Gehalt-Fenster, auch die rückschauenden. Wer im Ausgegeben-Log den Juli öffnete, fand die Ausgabe vom 30.07. nicht — sie stand unter August. Für die Rechnung stimmte das, für die Frage „was habe ich im Juli ausgegeben?" war es irreführend. Es sind zwei verschiedene Fragen an dieselben Daten:

Frage Zeitraum Funktionen
„Reicht das Geld bis zum nächsten Gehalt?" Fiskalmonat fiskalMonat / fiskalJahr
„Was habe ich im Juli ausgegeben?" Kalendermonat kalenderMonat / kalenderJahr

Rechnend heißt: die Zahl beantwortet eine Frage über verfügbares Geld — Saldo, Stand heute, Übertrag, Puffer, Prognose. Rückschauend heißt: die Zahl beantwortet eine Frage über getätigte Vorgänge — Log, Kategorie-Balken, Jahresübersicht, Gesamtrückblick. Jede neue Auswertung entscheidet sich bei ihrer Entstehung für eine Seite; lässt sie sich nicht eindeutig zuordnen, ist das ein Grund innezuhalten.

Das konkrete Beispiel: Gehalt am 29.07., Ausgabe am 30.07.

  • Kalender-Juli: ja (sie steht im Log des Juli, im Julibalken, im Rückblick Juli)
  • Fiskal-Juli: nein
  • Saldo August: ja — sie zehrt vom Geld, das am 29.07. kam

spentSum/spentIn/incomeSum/incomeIn nehmen ihr Fenster entgegen, statt es sich selbst zu holen; damit entscheidet der Aufrufer. fensterWeichenAb(y, m0) vergleicht beide Enden der Fenster — schiebt sich nur der Folgemonat nach vorn (Juli beginnt am 1., August am 29.07.), ist der Beginn identisch und dem Fiskaljuli fehlen trotzdem die letzten drei Tage.

Die Oberfläche redet nur dann von zwei Zeiträumen, wenn sie auseinanderfallen:

  • Kopfzeile im Log-Monat: groß der Kalendermonat, klein daneben „Fiskalmonat 29.06.–28.07." mit dessen Summe (#sumSide, zentral in render() über setSumSide('') zurückgesetzt, weil sieben Kopf-Funktionen #sumTotal bespielen).
  • Zeitraum-Angabe über den Kategorie-Balken („Ausgaben im Juli · 01.07.–31.07.").
  • Grenzgänger-Chip „→ Saldo August 2026" an der Karte (grenzgaenger(iso)). Er hängt am Posten, nicht an der Ansicht, und wirkt deshalb auch in der Jahres-Trefferliste. Nicht an Budget-Sammelkarten: die fassen mehrere Posten zusammen, ein einzelner Zuordnungshinweis wäre dort falsch.

Ohne markiertes Gehalt fallen beide Begriffe zusammen — dann erscheint nichts davon, und für solche Bestände ändert v2.25.0 weder Zahl noch Pixel (check-kalender.mjs, Fall 14).

Fiskalisch bleiben ausdrücklich: monatSaldo, monatCalc, standHeute, der Übertrag, das ganze monthStart[]-Modell, die Monatssaldo-Karte im Log (die fiskalische Insel in einer kalendarischen Ansicht — deshalb weist sie ihren Zeitraum selbst aus), „Laufend → Monat/Fixkosten" und „Geplant". Einen Umschalter Kalender/Fiskal gibt es bewusst nicht: die Doppelanzeige im Kopf beantwortet dieselbe Frage ohne Zustand.

Der Gesamtrückblick zeigt neben tatsächlichen Posten auch Fixkosten-Fälligkeiten. Kalendarisch kann ein Monat dadurch in seltenen Fällen zwei Gehälter oder keines enthalten, wenn der Zahltag um den Monatsersten schwankt. Das ist die Natur des Kalenderblicks und kein Fehler.

„Stand heute" (v2.24.0)

Wo steht das Geld gerade? Zeile in der Monatssaldo-Karte, direkt nach den Bewegungszeilen — sie schließt ab, was bis heute passiert ist, erst danach schaut die Karte mit „Bleibt übrig" voraus.

standHeute(y, m0) = Übertrag + Fixposten bis heute − erfasste Ausgaben + erfasste Einnahmen. Zwei Festlegungen unterscheiden sie von „Bleibt übrig":

  • Zeitschnitt heute statt Monatsende. Ein Fixposten, der erst in zwei Wochen fällig wird, zählt nicht mit. Dass ein fälliger Dauerauftrag auch abgebucht wurde, ist eine Annahme — sie steht im Erklärtext.
  • ALLE Ausgaben, auch die aus Budgets mit countInTotals:false. Aus der Auswertung mögen sie ausgenommen sein; vom Konto sind sie trotzdem weg.

zeigeStand lässt die Zeile nur im laufenden Monat und nur mit gesetztem Übertrag erscheinen: ohne Anker wäre die Zahl bloß die Summe der Monats- bewegungen und hieße trotzdem „Stand". Geeicht wird sie einmal im Monat, wenn beim Monatsbeginn der echte Kontostand als Übertrag eingetragen wird.

Bewusst kein Abgleichs-Werkzeug: die Zahl ist eine Hausnummer (Tag „ca."), kein Kontostand — für den genauen Stand loggt man sich beim Konto ein. Sie driftet mit jeder nicht erfassten Kleinigkeit nach oben; ±100 € ist der Anspruch.

Die Karte hat seither einen Erklärtext für alles (Zeitraum, Stand, Projektion) statt drei — der Aufklapper ist eine Eigenschaft der Karte, nicht der Zeile.

Gesamtrückblick (v2.17.0)

Fünfter Bereich; das Menüband ist dafür zweizeilig geworden — oben, wo Daten gepflegt werden (Geplant · Laufend · Ausgegeben), unten, wo sie ausgewertet werden (Budgets · Gesamtrückblick). Die drei gewohnten Bereiche behalten ihre Position.

Die Frage „was kostet mich ein Thema über die Zeit?" ließ sich vorher nicht beantworten, weil sie in zwei Domänen liegt: der Telefonvertrag als Fixposten (Soll, taucht nie im Log auf), der Router als tatsächliche Ausgabe (Ist). Beide tragen dieselbe Kategorie, aber keine Erfassungsansicht führt sie zusammen. Der Rückblick ist eine Leseschicht darüber und schreibt nichts (kein FAB, keine Reiter); die strikte Trennung Ist/Soll beim Erfassen bleibt unangetastet.

Kommunikation                              703,80 €
Laufende Kosten                            514,80 €
  Telefonvertrag   12 × 29,90 €            358,80 €
  Mobilfunk        12 × 13,00 €            156,00 €
Tatsächliche Ausgaben                      189,00 €
  Router           12.03.2026              189,00 €
Zusammen                                   703,80 €
  • Zeitraum: Monat · Jahr · Seit Beginn · Frei (Vorgabe Jahr), rueckRange().
  • Startbild: Balken der obersten Ebene über Fixkosten-Fälligkeiten und tatsächliche Ausgaben, pfadbasiert und partitionierend (catBars), Antippen bohrt eine Ebene tiefer. Kategorie-Normalisierung für beide Domänen über catKey.
  • Detail erreichbar über das Suchfeld (namensbasiert, mit Fundort-Aufschlüsselung) oder einen Balken (pfadbasiert).
  • Einnahmen erscheinen nur, wenn es unter der Kategorie welche gibt, und dann als eigener Block mit Umschalter — eine erzwungene Bilanz wäre eine Aussage, die die Daten nicht hergeben.

Verlauf über die Zeit (v2.26.0)

Über dem Kategorie-Balken steht seit v2.26.0 eine Säule je Zeitabschnitt: laufende Kosten unten (der Sockel, der sich selten ändert), erfasste Ausgaben darüber (das, was schwankt). Die Balken sagen, wofür das Geld ging; ob es mehr wird, sagten sie nicht — über einen langen Zeitraum ist aber genau das die Frage.

  • Einteilung aus der Länge des Rahmens (rueckBuckets()): bis 36 Monate je Monat, darüber je Jahr; Ränder eines freien Zeitraums werden angeschnitten und heißen dann nicht mehr „März 2026", sondern 10.03.2026 – 31.03.2026. Ein einzelner Monat bekommt keinen Verlauf — eine Säule ist keine Entwicklung; dafür ist das Ausgegeben-Log da, das den Kalender führt.
  • Antippen grenzt ein (state.rueck.bucket), dieselbe Geste wie beim Kategorie-Balken, nur in der anderen Richtung. Kopf, Balken und Detail folgen dem Abschnitt (rueckActiveRange()), die Säulen selbst nicht — sonst bliebe nach einem Tipp genau eine übrig und es gäbe keinen Weg zurück. Aus demselben Grund steht die Karte auch dann da, wenn der gewählte Abschnitt leer ist.
  • Beides gilt nebeneinander: erst „Wohnen", dann „März" ist eine sinnvolle Frage; der Zeit-Filter hebt den Kategorie-Filter nicht auf. Ein Wechsel des Rahmens (Spanne, Blättern, freies Datum) wirft den Abschnitt dagegen weg — seine Grenzen stammen aus dem alten Rahmen.
  • Vor dem Datenbeginn wird schraffiert statt genullt, wie in der Jahresansicht des Logs (Festlegung 5 unten).
  • Kalendarisch, wie der ganze Rückblick (siehe „Zwei Monatsbegriffe"): eine Monatssäule läuft vom Ersten bis zum Letzten, nicht von Gehalt zu Gehalt. Der Verlauf beantwortet „was war im März?", keine Frage über verfügbares Geld. Die Grenzen fallen ohnehin nicht selbst, sondern erben sie von rueckRange().

Nichts an den Regeln ist dafür angefasst worden: was als Ausgabe zählt, wie Fixkosten gezählt werden (recGroupsIn je Abschnitt) und wie Kategorien normalisiert werden, beantworten weiter dieselben Funktionen wie der Rest des Rückblicks. Neu sind die Einteilung, ein Ansichtszustand — und die eine Stelle, an der beides zusammenläuft: rueckEntries() liest seit v2.26.0 durch rueckActiveRange() statt direkt durch rueckRange(). Ohne gewählten Abschnitt sind beide identisch; wer künftig am Zeitraum des Rückblicks etwas ändert, muss aber wissen, dass hier ein zweiter Trichter dazwischenhängt.

Die tragenden Festlegungen (PLAN-rueckblick.md):

  1. Vergangenheit. Geplante Posten (items) kommen nicht vor, sie sind Zukunft.
  2. Gezählt, nicht hochgerechnet (recDueIn), inklusive Zeitscheiben: im Juli gilt der Juli-Betrag.
  3. Sauber addierbar, weil Fixkosten nie abgehakt und nie als Ausgabe gebucht werden — recurring und spent können sich nicht überschneiden. Diese Regel muss erhalten bleiben: ein „bezahlt?"-Häkchen an Fixposten würde sie brechen.
  4. Budgets kommen nicht vor. Ein Budget ist ein Vorsatz („im Urlaub höchstens 1.000 €"), kein Geldtopf — das Geld kommt aus demselben Verfügbaren, und was wirklich ausgegeben wird, steht ohnehin in spent. Gelesen wird spent vollständig, also ohne countInTotals: der Log darf ausblenden, der Gesamtrückblick zeigt alles. Liegen ausgenommene Budget-Ausgaben im Zeitraum, zeigt der Rückblick darum eine höhere Summe als der Log-Kopf — beabsichtigt.
  5. Grenzen benennen statt verschweigen. Der Datenbeginn wird aus dem frühesten Eintrag abgeleitet (dataStart(), kein Feld, kein Sync-Thema). Reicht der Zeitraum davor, sagt der Rückblick das — und nennt die Fixposten ohne „läuft ab", die deshalb über diesen Anfang hinaus mitgerechnet werden. In der Jahresansicht des Logs werden Monate vor dem Datenbeginn schraffiert als „keine Daten" gezeichnet, unterscheidbar von einem leeren Monat, der eine echte Null ist.

Nebenbei mit v2.17.0: die Kalender-Emojis 📅/🗓️ in den Leer-Zuständen sind raus (ICON_CAL). Sie tragen auf Android ein aufgedrucktes Datum („17 July") und lesen sich in einem Zustand, der von einem anderen Monat spricht, wie eine falsche Angabe — dieselbe Regel wie beim 🔁 in v1.6.1.

Partner-Sync (v2.0.0, PLAN-sync.md)

Zwei Telefone teilen einen per QR gekoppelten AES-256-Schlüssel (AndroidKeyStore, nicht exportierbar; neu koppeln = neuer Schlüssel) und gleichen ihren Stand direkt im WLAN ab (NSD _finanzplan._tcp + manueller IP:Port-Fallback, AES-GCM pro Nachricht). Der Merge läuft in window.onSyncData: LWW pro id über updatedAt, deterministischer Tie-Break, Tombstones — kommutativ + idempotent, beide Geräte konvergieren. Backup-Import bleibt bewusst „Ersetzen".

Fehlerbehebung Sync (Erkenntnisse aus dem Zwei-Geräte-Real-Test, v2.0.1/v2.0.2)

  • „socket failed: ECONNREFUSED" (SocketException, nicht ConnectException) heißt: die Socket-Erzeugung scheitert — das Betriebssystem verweigert der App den Netzzugriff komplett. Mit WLAN, Router oder dem Partnergerät hat das nichts zu tun. Häufigste Ursache: Die INTERNET-Berechtigung kam erst per Update dazu (Finanzplan war vor v2.0 netzlos) und wurde vom System nicht wirksam übernommen — auch ein Geräte-Neustart hilft dann nicht. Lösung: Backup exportieren, App deinstallieren, neu installieren, neu koppeln. (So 2026-07-17 real bestätigt.)
  • Der Fehlerdialog (seit v2.0.2) zeigt Ziel-IP:Port, die Original-Exception und einen Loopback-Selbsttest (SyncManager.selfTest()): schlägt schon der fehl, liegt es sicher am eigenen Gerät (Berechtigung/Firewall-App), nicht am Netz. Dialogtitel tragen die App-Version, damit klar ist, welcher Build wirklich läuft.
  • NSD niemals beim Discovery-Event auflösen und cachen (v2.0.1-Fix): Der Empfänger öffnet bei jedem „Empfangen" einen neuen Zufallsport (ServerSocket(0)), gecachte IP:Port-Einträge zeigen also auf tote Ports. Auflösen erst beim Antippen (resolveThenConnect), IPv4 bevorzugen, onServiceLost räumt die Liste auf.
  • Echtes ConnectException mit korrektem Ziel: am Empfänger „Empfangen" neu starten und IP:Port mit dessen „Warte auf Partner"-Anzeige abgleichen (oder manuell eingeben).

Strikte Domänentrennung: Geplant und Laufend werden nie stillschweigend vermischt; Kopfsummen behalten stabile Bedeutungen, Mischsichten sind explizit markierte Opt-ins (Tags wie „inkl. Fixkosten").

Sicherheit — Tresor: PIN + Verschlüsselung (v2.1.0, Pflicht seit v2.15.0, Vault.kt)

Seit v2.15.0 Pflicht. Ohne eingerichtete PIN zeigt die App nichts als den Einrichtungs-Bildschirm (#setupScreen, Boot-Gate bei vaultState()==='off'); einen Weg, die Verschlüsselung wieder abzuschalten, gibt es in der Oberfläche nicht mehr. Beim ersten Start nach dem Update verschlüsselt setPin() den vorhandenen Klartext-Bestand mit. Menü → „Sicherheit & PIN" führt danach nur noch PIN ändern und die Wartezeit der Auto-Sperre.

Verschlüsselung im Ruhezustand (At-Rest). Ist eine PIN gesetzt, liegt data.json nur noch als AES-256-GCM-Chiffrat vor. Der Daten-Schlüssel (DEK, 256 Bit Zufall) wird doppelt gekapselt (Defense in Depth, beide Schichten müssen fallen):

pinKey  = PBKDF2-HMAC-SHA256(PIN, salt, 210 000)   ← aus der PIN abgeleitet
inner   = AES-GCM(pinKey,  DEK)                     ← PIN-Schicht
wrapped = AES-GCM(KEK_ks,  inner)                   ← Hardware-Schicht (AndroidKeyStore)
  • KEK_ks liegt nicht-exportierbar im AndroidKeyStore (hardware-gebunden). Damit lässt sich die kurze PIN nach einer Datei-Extraktion nicht offline durchprobieren — ohne das konkrete Gerät kommt niemand an inner.
  • Falsche PIN scheitert an der GCM-Prüfung der inneren Schicht (= Verifier, kein separater PIN-Hash nötig). Nach 5 Fehlversuchen greift eine sich verdoppelnde Sperrzeit (15 s … 5 min), in vault.meta persistiert.
  • Der DEK lebt nur im RAM, solange entsperrt. Kaltstart = wieder gesperrt.

Auto-Sperre (v2.15.0). Der Kaltstart allein reichte nicht: ein Prozess kann tagelang leben, dann fragt die App praktisch nie wieder nach der PIN. onStop merkt sich SystemClock.elapsedRealtime(), onStart verschließt den Tresor, wenn die Pause länger war als die eingestellte Wartezeit (fp_sec/autolock_min, Voreinstellung 2 Minuten, wählbar sofort/1/2/15). Danach webView.reload() — so verschwinden auch die Beträge aus dem Speicher der Seite. Ausgenommen sind Ausflüge, die die App selbst startet (QR-Scanner, SAF-Dateiauswahl; Flagge eigenerAusflug) — sonst sperrt man sich mitten im Koppeln aus. Ein wartender Abgleich wird beim Sperren beendet, und handleConnection weist Verbindungen bei verschlossenem Tresor ab.

PIN-Sperre (Boot-Gate). Der Sperrbildschirm liegt in app.html (Ein-Datei- Philosophie), nur das Schlüsselmaterial ist nativ. boot() zeigt bei vaultState()==='locked' ausschließlich den Sperrbildschirm; load() liefert im verschlossenen Zustand leer, save() ist ein No-op. Kein Klartext-Spiegel: persist() schreibt in der App nur noch über die Bridge — der frühere localStorage-Spiegel war eine zweite, unverschlüsselte Kopie im WebView-Verzeichnis (gelesen wird er ohnehin nur ohne Bridge, also im Desktop-Client und im Browser). dropPlainMirror() räumt alte Bestände beim Start weg. currentDataJson() (Sync) entschlüsselt über den Tresor — der Abgleich läuft ohnehin nur entsperrt.

Backup mit Passphrase (WebCrypto, in app.html). Bewusst getrennt vom KeyStore, damit Backups geräteunabhängig wiederherstellbar sind und die Logik in einer Datei bleibt (mit der Playwright-Suite testbar). Export fragt eine Passphrase ab (leer = unverschlüsselt); Envelope:

{ "fpenc":1, "kdf":"PBKDF2-HMAC-SHA256", "iter":210000, "salt":"b64", "iv":"b64", "ct":"b64" }

Import erkennt den fpenc-Marker und fragt die Passphrase ab; unverschlüsselte Alt-Backups werden weiter angenommen.

Vergessene PIN = Rettung nur über ein passphrase-Backup. Der Sperrbildschirm bietet „PIN vergessen? Backup wiederherstellen": Backup wählen → im JS entschlüsseln → vaultRecover(json, neuePin) setzt den Tresor mit frischem DEK und neuer PIN neu auf. Es gibt keine Hintertür — ohne PIN und ohne Backup-Passphrase sind die Daten verloren (so gewollt).

Nichts verlässt das Telefon (v2.15.0). android:allowBackup="false" und @xml/data_extraction_rules, das Cloud-Backup und Gerät-zu-Gerät-Übertragung ausschließt (ab Android 12 hängt der Direkt-Transfer nicht mehr an allowBackup). Der einzige Weg heraus ist der eigene Export. Dazu: setWebContentsDebuggingEnabled nur noch in Debug-Builds (sonst liest jeder mit USB-Debugging an der PIN vorbei mit) und setRecentsScreenshotEnabled(false) ab API 33, damit in der App-Übersicht kein Kontostand steht.

window.prompt() liefert in der WebView null (kein onJsPrompt), daher ein eigener promise-basierter Eingabe-Dialog (askInput) statt prompt().

UI-Einstellungen (nur localStorage, nie im Datenschema)

Key Bedeutung
fp_budgetOpen Budget-Band in „Ausgaben" auf-/zugeklappt
fp_budgetBasis Budget-Basis-Betrag
fp_budgetScope Band-Rechnung: geplant (Default) / mitlauf
fp_ovRecurring Übersicht: Fixkosten in Bilanz einbeziehen (Opt-in)
fp_ovNoOpt Übersicht: optionale Ausgaben ausblenden (Opt-out)
fp_density Listendichte
fp_theme Erscheinungsbild: system (Default) / hell / dunkel
fp_noetig Farbe der „nötig"-Streifen (v2.14.0), Vorgabe #D97706
fp_brand Grundfarbe der App (v2.20.0), Vorgabe #6D28D9

Runder Farbwähler (v2.21.0)

Hinter jeder festen Palette steht ein Knopf, der ein Farbrad öffnet: Farbton = Winkel, Sättigung = Abstand zur Mitte, Helligkeit = Regler darunter. Ein Bauteil (openFarbwaehler({titel, farbe, live, fertig}), #fwLayer) für alle vier Stellen — Grundfarbe, „nötig"-Streifen, Kategorie-Farbe, Budget-Farbe. Die Paletten bleiben davor stehen: sie sind der schnelle Weg, das Rad der freie.

  • **Keine Bibliothek, kein <canvas>, kein <input type="color">.** Letzteres ist in der Android-WebView je nach Version wirkungslos. Das Rad ist reine CSS-Anzeige (conic-gradient+radial-gradient); **welche** Farbe unter dem Finger liegt, wird aus Winkel und Radius gerechnet (fwHsvHex/fwHexHsv`, HSV), nicht aus Pixeln gelesen. Dadurch stimmt der Wert bei jeder Bildschirmdichte, und das Rad kann beliebig groß sein.
  • fwDrag fasst Maus und Finger über Pointer-Events zusammen; setPointerCapture hält das Ziehen fest, wenn der Finger das Rad verlässt (Sättigung wird dann auf 1 geklemmt, der Regler auf 0…1).
  • Der Wähler liegt über den Blättern, ist aber selbst keins (z-index:70, kein .sheet): closeSheets() würde sonst das Menü darunter mitreißen. onAndroidBack nimmt deshalb zuerst nur das Rad weg.
  • Vorschau ohne zu speichern: live(hex) färbt bei jeder Bewegung nur die CSS-Variablen (fpApplyBrand(hex) nimmt dafür seit v2.21.0 eine Farbe von außen entgegen). Android.setBrand und das Neuzeichnen der Homescreen-Kachel laufen erst beim Übernehmen — pro Fingerbewegung wäre das viel zu teuer. Abbrechen ruft live(start) und stellt damit den Ausgangszustand her.
  • Der Hinweis ist ein Hinweis, kein Verbot: Grün heißt in der App Einnahme, Rot heißt überfällig. fwHinweis() sagt das bei entsprechendem Farbton. Die Zeile steht immer (nur visibility), sonst wüchse das Blatt genau in dem Moment, in dem der Hinweis erscheint, und das Rad spränge unter dem Finger weg.
  • --on-brand (v2.21.0): Die Kopfleiste trug ihre Schrift fest in Weiß — bei einer frei gewählten hellen Grundfarbe unlesbar. fpApplyBrand rechnet jetzt aus der Farbe, ob Schrift und aktive Reiterflächen dunkel oder hell sein müssen (--on-brand), und dreht die durchscheinenden Flächen im Kopf mit (--brand-veil, --brand-warn). Der aktive Reiter dreht die Farben um: dunkles Feld mit farbiger Schrift statt weiß.

Dunkelmodus (v2.9.0)

Menü → Erscheinungsbild schaltet durch Folgt dem System · Immer hell · Immer dunkel. Drei Stellen greifen ineinander:

  • Seite: ein kurzes Skript im <head> löst die Wahl auf und setzt data-theme="hell|dunkel" am <html> — noch vor dem ersten Malen, sonst blitzt die helle Seite auf. Das restliche CSS kennt nur noch dieses Attribut (keine Media-Query verstreut im Stylesheet). Die Markenfarbe bekommt im Dunkeln ein aufgehelltes Geschwister --accent, weil Petrol auf fast schwarzem Grund als Schrift untergeht.
  • Shell: ohne WebSettingsCompat.setAlgorithmicDarkeningAllowed(…, true) meldet die WebView seit targetSdk 33+ immer „hell" — dann bliebe der Dunkelmodus auf dem Gerät unsichtbar, egal was im CSS steht. Das <meta name="color-scheme" content="light dark"> sagt Android zugleich, dass die Seite ihren eigenen Dunkelmodus mitbringt und nichts nachgedunkelt werden muss.
  • Merken: Android.setTheme(pref) legt die Wahl in den SharedPreferences ab; FinanzApp setzt daraus beim Start setDefaultNightMode, damit Fensterhintergrund und Systemleisten passen. Zur Laufzeit wird nur der Fensterhintergrund umgefärbt — ein Moduswechsel würde die Activity neu bauen und die offene Seite mitreißen.

Build

Gradle 8.13 braucht JDK 21 (Java 25 bricht):

export JAVA_HOME=/usr/lib/jvm/java-21-temurin-jdk
./gradlew assembleDebug
# → app/build/outputs/apk/debug/app-debug.apk

minSdk 26, target/compile 35, Package com.thee.finanzplan.

Ausgeliefert wird über ein eigenes F-Droid-Repo: assembleRelease bauen, das unsignierte APK ausrichten und mit dem eigenen Release-Key signieren (zipalign, apksigner), dann ins Repo legen. versionCode dabei immer hochzählen, sonst bietet F-Droid kein Update an.

Tests

Headless-Browser-Suite (playwright-core + chrome-headless-shell) unter tests/, zusammen 990 Assertions:

Datei Prüft
test.mjs (85) Kernfunktionen: CRUD, Summen, Erledigen, Backup, Formatierung
check-v13.mjs (30) Wiederkehrende Posten + Quartal/Halbjahr, Undo-Löschen
check-puffer.mjs (25) „Geplanter Puffer"-Band inkl. Scope-Umschalter („Puffer inkl. Laufendem")
check-budgets.mjs (44) Budgets als vierter Bereich im Band, Detail-Drill-in + Zurück-Kette, listMode (einzeln/Sammelkarte/aus), countInTotals + Kopf-Hinweis, Kategorie-Vorbelegung, Lösch-Dialog (beide Zweige) + Undo, v6-Backup lädt mit Defaults (v2.8.0)
check-ui.mjs (37) UI-Redesign, zweizeiliges 5-Segment-Band (v2.17.0: Pflegen oben, Auswerten unten; kein Umbruch je Zeile, „Gesamtrückblick" nicht abgeschnitten), Dichte
check-ov.mjs (35) Übersicht: beide Bilanz-Toggles, Kombi, Guards
check-ovcats.mjs (25) Übersicht → Kategorie-Balken aufdröseln (v2.18.0): Ebenen und Summen, „direkt"-Zeile, Brotkrume, Filterband mit Pfad, Postenliste unter den Balken, tieferer Tipp ersetzt den Pfad, Einnahmen-Balken bleiben stumm, Zusammenspiel mit „ohne Optionale", Reset beim Reiterwechsel
check-sync.mjs (44) v5-Migration, Merge (LWW/Tombstones/Kommutativität), Sync-Sheet, seriesId überlebt den Merge (inkl. LWW auf einer Scheibe), v10-Partner bleibt ladbar und bekommt v11 zurück
check-vault.mjs (39) Tresor: Backup-Krypto-Round-Trip, Export/Import-Passphrase-UI, Sperrbildschirm, PIN-Pflicht-Gate, kein Klartext-Spiegel im localStorage, Sicherheits-Blatt (PIN ändern, Auto-Sperr-Wartezeit)
check-log.mjs (42) Ausgegeben-Logbuch: Monatsfokus + ‹›-Navigation, Kategorie-Filter, Jahresansicht (Monatsbalken + Aufschlüsselung), Tags, Sync/Undo (v2.4.0)
check-monfilter.mjs (21) Kategorie-Filter in Laufend → Monat (seit v2.10.0 fix-only): Balken antippen → Posten einschränken, Wechseln, Aufheben, Reset bei Monatswechsel
check-saldo.mjs (42) Monatssaldo unter Ausgegeben → Monat (ohne/mit Projektion, „Noch offen"-Liste) und „Abhaken wird zur Buchung" inkl. Nachfrage, Undo, Datum aus der Fälligkeit (v2.10.0/v2.12.0)
check-carry.mjs (42) Übertrag/Startwert je Monat (v2.11.0): Vorschlag aus dem Vormonat, Vorzeichen über die Art-Auswahl, Wirkung auf beide Saldo-Zahlen, ein Satz je Monat, Löschen + Undo (Tombstone), Sync-Merge über die feste carry-YYYY-MM-id, Altbestand ohne carry
check-theme.mjs (17) Dunkelmodus: System-Automatik, feste Wahl (System/Hell/Dunkel), kein Aufblitzen beim Start, aufgehellter Akzent (v2.9.0)
check-stand.mjs (19) „Stand heute" (v2.24.0): ohne Übertrag keine Zahl, Zeitschnitt heute (fälliger Fixposten zählt, späterer nicht), Ausgaben inkl. ausgenommener Budgets, Abstand zu „Bleibt übrig" nach Zeitschnitt und Budget-Ausnahme, nur im laufenden Monat
check-monatsstart.mjs (69) Monatsbeginn (v2.23.0): Schätzung über den Tag des markierten Postens, Ausgabe vom 30. zählt zum Folgemonat (mit echtem Datum), der markierte Posten liegt auf dem gesetzten Tag statt danebenzufallen, Jahr = Summe seiner Monate, Erinnerungsband nur im laufenden Monat, Blatt mit Folgen-Ansage/Warnung/Übertrag, Löschen + Undo, Markierung nur an einem Posten und nur monatlich, Sync über die feste start-YYYY-MM-id, Altbestand ohne monthStart
check-income.mjs (48) Einnahmen-Logbuch (v2.12.0): Art-Umschalter im Blatt, grüne Karte in derselben Monatsliste, „Erhalten"-Zeile im Monatssaldo + Netto im Kopf, Art-Wechsel (neue id + Tombstone), Löschen/Undo, Abhaken einer geplanten Einnahme mit Fälligkeitsdatum, Sync mit einem Partner ohne income
check-cats.mjs (81) Pfad-Kategorien + Farben (v2.13.0): pathNorm/pathUnder/pathCut, Normalisierung beim Laden, Balken je Ebene mit Aufklappen und Pfad-Leiste, Präfix-Filter („Freizeit" zählt die Oper mit), Mehrfachwahl auf der Nachbarebene, Pillen mit Pfad (seit v2.16.0), Vorzeichen ±, Farbwahl je oberster Ebene (cat:Name) mit vererbtem Aufhellen, „Automatisch" + Tombstone, Zweig umbenennen über alle Listen samt Farbe, Vorschläge nach /, Sync mit einem Partner ohne catcolors
check-cats2.mjs (53) Nachschärfungen (v2.14.0): catHomes/anchorPath (Einhängen an der bekannten Stelle, Grenzfälle), Eintragen von „Musik/Album“ landet unter „Freizeit/Musik“, Auswertung findet Album und Konzerte, Hierarchie in den Balken (eigene Ebene zuerst, Kinder eingerückt), Vorschläge eine Ebene tiefer, kein Warnblock mehr (die Suche summiert selbst über beide Orte), Streifenfarbe (Vorgabe, Wahl, Merken, Aufhellen im Dunkeln, nicht im Sync)
check-search.mjs (78) Suche (v2.16.0/v2.17.0): hasSeg auf ganzen Ebenen (Wein ≠ Weinregal), pathShort an den Pillen, Vorschläge aus dem durchsuchten Bestand mit segStats (Summe/Anzahl/Orte) und ohne Doppelzählung, Treffer über Oberkategorien hinweg, Fundort-Aufschlüsselung addiert sich auf die Suchsumme (pathUpTo), Budget-Sammelkarten lösen sich auf, Suche + Balkenfilter = UND, Monatsmarkierung im Jahr (Punkt filtert, Zeile springt), Jahresliste nur bei aktiver Suche, Rückfrage bei zwei möglichen Orten, kein Warnblock mehr
check-verlauf.mjs (86) Fixkosten-Verlauf (v2.17.0–v2.17.3): Einstieg über den Editor (einzelner Posten → Blatt mit beiden Richtungen → zwei dichte Zeiträume, danach „Verlauf öffnen · N"), rückwärts nachtragen mit offenem Beginn („von Anfang an" + Rückfrage zum heutigen Betrag), Migrationsweg mit Rückfrage, Scheibe vorn/hinten/mittig einfügen, abgeleitete Enddaten, jeder Monat genau einmal belegt, Überlappung nicht konstruierbar, Löschen schließt die Lücke (+ Undo), seriesId-Vergabe und -Wegfall, Serienfelder schlagen auf alle Scheiben durch, „Läuft bis" nur an der jüngsten, „Früher"-Abschnitt, Suche in der Fixkostenliste, Ø pro Monat zählt je Serie EINE Scheibe und nichts Beendetes (Kopf, Übersicht, Puffer-Band, Suchvorschlag)
check-rueck.mjs (88) Gesamtrückblick (v2.17.0): recDueIn über alle vier Rhythmen inkl. Anker und Tagesgrenzen, Zeitscheiben-Wechsel mitten im Jahr (7 × 850 + 5 × 890), Fixkosten + spent in einer Summe, Detailansicht Zeile für Zeile, Fundorte, Einnahmen-Block, alle vier Zeiträume, Datenbeginn-Ableitung + „keine Daten"-Schraffur, geplante Posten und Budget-Ausnahmen bleiben draußen, der Bereich schreibt nichts. Verlauf (v2.26.0): Einteilung Monat/Jahr, angeschnittene Ränder mit echten Grenzen, Antippen grenzt ein und lässt die Säulen stehen, Kombination mit dem Kategorie-Filter, Rahmenwechsel wirft den Abschnitt weg, Schraffur vor dem Datenbeginn, ein einzelner Monat bekommt keine Säulen
check-widget.mjs (22) Homescreen-Kachel „Ausgabe" (v2.19.0/v2.20.0): Kachel-Auftrag öffnet den Ausgaben-Editor im Bereich „Ausgegeben", normaler Start bleibt unberührt, der Auftrag überlebt den Sperrbildschirm und zündet erst nach dem Entsperren, Rückkehr in die laufende App über visibilitychange, genau einmal abgeholt, ohne Bridge wirkungslos; Speichern schließt die App nur nach einem Kachel-Start (nicht selbst geöffnet, nicht nach abgebrochenem Blatt)
check-farbwahl.mjs (64) Runder Farbwähler (v2.21.0): Winkel/Radius → Farbton/Sättigung, Helligkeitsregler klemmt beim Hinausziehen, Hex-Feld (Zwischenstände beim Tippen ändern nichts), Vorschau färbt die Seite ohne zu speichern und ohne die Shell zu rufen, Abbrechen stellt her, Rad-Knopf an allen vier Stellen (eigene Farbe sitzt sichtbar darauf), Kategorie- und Budgetfarbe übers Rad, Hinweis bei Grün/Rot ohne dass das Blatt springt, Zurück nimmt nur das Rad weg, lesbare Schrift auf heller Grundfarbe (--on-brand), HSV hin und zurück
check-brand.mjs (24) Grundfarbe der App (v2.20.0): Vorgabe Violett, Palette im Menü, Wechsel färbt sofort um und wird gemerkt, abgeleitete Töne (dunkleres Verlaufsende, aufgehellter Akzent im Dunkeln, umgedrehter Band-Knopf), kein Aufblitzen vor dem ersten Malen, Sperrbildschirm trägt sie mit, kaputter Wert fällt auf die Vorgabe, Shell wird informiert, ohne Bridge wirft nichts
check-kalender.mjs (63) Zwei Monatsbegriffe (v2.25.0): Log/Balken/Suche/Jahresansicht/Monatsmarkierung/Gesamtrückblick kalendarisch, Saldo + Stand + Übertrag fiskalisch, Doppelanzeige im Kopf (nur bei abweichenden Fenstern, hängt in keinem anderen Kopf nach), Grenzgänger-Chip am Posten, Jahreswechsel, und der Regressionsfall: ohne markiertes Gehalt ändert sich nichts
check-desktop.mjs (12) Rauchtest der nach finanzplan-desktop kopierten app.html: ohne Android-Bridge (localStorage), desktop_ui.js injizierbar, Monatssaldo rechnet auch dort (DESKTOP_DIR setzbar)
shot-*.mjs Screenshots (hell/dunkel) nach ~/scratchpad-shots/
cd tests && npm install
export CHROME=…/chrome-headless-shell   # z. B. aus `npx playwright install chromium-headless-shell`
node test.mjs && node check-v13.mjs && node check-puffer.mjs \
  && node check-budgets.mjs && node check-ui.mjs && node check-ov.mjs \
  && node check-sync.mjs && node check-vault.mjs && node check-log.mjs \
  && node check-monfilter.mjs && node check-theme.mjs && node check-saldo.mjs \
  && node check-carry.mjs && node check-monatsstart.mjs && node check-stand.mjs && node check-income.mjs && node check-cats.mjs \
  && node check-cats2.mjs && node check-search.mjs \
  && node check-verlauf.mjs && node check-rueck.mjs && node check-ovcats.mjs \
  && node check-widget.mjs && node check-brand.mjs && node check-farbwahl.mjs \
  && node check-kalender.mjs

Der Tresor selbst (KeyStore/PBKDF2 in Vault.kt) braucht ein Gerät/Emulator; die Backup-Passphrase-Krypto ist reines WebCrypto und daher in check-vault.mjs im echten WebView-Engine abgedeckt.

EXEC in den Skripten zeigt auf den lokalen playwright-Browser-Cache — bei Bedarf anpassen (npx playwright install chromium-headless-shell).

Bekannte Fallstricke stehen in den Skripten als Kommentare; wichtig: de-DE-Negativbeträge sind in textContent ASCII-Hyphen (-640,00), im HTML dagegen − (U+2212); addInitScript läuft bei jedem Reload erneut und leert dann ggf. localStorage.

Historische Planungsdokumente

  • PLAN-kalendermonat-rueckblick.md — zwei Monatsbegriffe: Rückschau kalendarisch, Rechnung fiskalisch (v2.25.0)
  • PLAN-rueckblick.md — Gesamtrückblick, bereichseigene Suche, Fixkosten-Verlauf (v2.17.0)
  • PLAN-wiederkehrend.md — Einführung wiederkehrender Posten (v1.3)
  • PLAN-ui-redesign.md — UI-Redesign in Phasen (abgeschlossen mit v1.7/1.8)