- JavaScript 50%
- HTML 39.5%
- Kotlin 10.5%
| app | ||
| gradle | ||
| tests | ||
| .gitignore | ||
| build.gradle.kts | ||
| COPYRIGHT | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
| sync-paired-dark.png | ||
| sync-unpaired-light.png | ||
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()undime()als Ränder (layoutParams.topMarginusw., untenmaxOf(Tastatur, Navigationsleiste)) auf die WebView und gibtWindowInsetsCompat.CONSUMEDzurü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 v11seriesId(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 wiespent[], aber ohnebudgetId— Budgets sind Ausgabentöpfe. Eigene Liste statt eineskind-Feldes inspent[]: ein Partner auf ≤2.11 kenntnormalizeSpentohnekind, 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 beicarry[]). 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 optionalbudgetId; 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(Standardtrue) — 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. Dieidist 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,idwie beicarry[]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}. Dieidist wie beicarry[]aus dem Namen abgeleitet, damit zwei Geräte denselben Satz treffen. Ohne Eintrag würfeltcatColorBase()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 vonapplyNoetig()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-LeistecatCrumbHtml). 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 imtitlebekommt 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/Musikmit 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
queryHomesHtmldie 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.histFromEditorspeichert das offene Blatt zuerst (saveRecmeldet 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,resealSerieserledigt 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.recurringsummiert, 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 übercurrentRecs()/recEquivSum()(je Serie die gültige Scheibe, nichts Abgelaufenes): Fixkosten-Kopf „Ø pro Monat (fix)", der Fixkosten-Block der Übersicht und das Puffer-Band. EbensosearchEntries('fix')— der Vorschlag versprach sonst „Wohnen 1.740,00 € · 2 Posten", das Trefferband zeigte danach 850,00 €. Merke: seit den Zeitscheiben iststate.recurringkeine Liste von Posten mehr, sondern eine Liste von Zeiträumen. Die Monatssichten waren nie betroffen, sie rechnen überrecInMonthdatumsgenau.
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 ausmonatCalc+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 denanchorDaydes markierten Postens: liegt er hinterSTART_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/yearRangegibt 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 inrender()übersetSumSide('')zurückgesetzt, weil sieben Kopf-Funktionen#sumTotalbespielen). - 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 übercatKey. - 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", sondern10.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):
- Vergangenheit. Geplante Posten (
items) kommen nicht vor, sie sind Zukunft. - Gezählt, nicht hochgerechnet (
recDueIn), inklusive Zeitscheiben: im Juli gilt der Juli-Betrag. - Sauber addierbar, weil Fixkosten nie abgehakt und nie als Ausgabe gebucht
werden —
recurringundspentkönnen sich nicht überschneiden. Diese Regel muss erhalten bleiben: ein „bezahlt?"-Häkchen an Fixposten würde sie brechen. - 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 wirdspentvollständig, also ohnecountInTotals: 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. - 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,onServiceLosträumt die Liste auf. - Echtes
ConnectExceptionmit 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_ksliegt 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 aninner.- 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.metapersistiert. - 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. fwDragfasst Maus und Finger über Pointer-Events zusammen;setPointerCapturehä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.onAndroidBacknimmt 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.setBrandund das Neuzeichnen der Homescreen-Kachel laufen erst beim Übernehmen — pro Fingerbewegung wäre das viel zu teuer. Abbrechen ruftlive(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 (nurvisibility), 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.fpApplyBrandrechnet 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 setztdata-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;FinanzAppsetzt daraus beim StartsetDefaultNightMode, 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)