diff --git a/Changes.md b/Changes.md index adcdde7..9e07578 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,39 @@ # Änderungen +## Cockpit: Vermögensentwicklung als gestapeltes Balkendiagramm (11.08.2026) + +- Die Kachel *Vermögensentwicklung* war ein Platzhalter und zeigt jetzt die Kontostände + der letzten zwölf Monate je Monatsende – ein Balken pro Monat, ein Farbton pro Konto + (`Vermoegensentwicklung::reihe()`, `src/Views/cockpit/vermoegensdiagramm.php`). +- Negative Stände stapeln unter der Nulllinie. +- Schalter *nur aktive* oben rechts an der Kachel, im Standard eingeschaltet: Er blendet + stillgelegte Konten samt Legendeneintrag aus und merkt sich seinen Zustand über die + Einstellungen (`cockpit_vermoegen_nur_aktive`) – auch über das Abmelden hinaus. Er ist + das einzige Kriterium; das Σ-Kennzeichen filtert hier **nicht** mit, weil stillgelegte + Konten es meist nicht tragen und beim Ausschalten sonst genau das fehlte, was man sehen + will. +- `Buchung::bewegungenJeKontoUndMonat()` liefert die Monatsbewegungen in einer Abfrage je + Quelle – zwölf Stichtagsabfragen wären es sonst. +- Mehr Konten als Farbtöne: Die größten behalten ihre Farbe, der Rest wird zu *Übrige + Konten (n)* zusammengefasst. Zwei Konten in derselben Farbe wären in der Legende nicht + auseinanderzuhalten. +- Eigenes SVG, keine Diagrammbibliothek. Die Farben stehen als Klasse am Element und + folgen damit dem hellen wie dem dunklen Thema; jedes Balkenstück trägt ein `` + als Tooltip, ohne eine Zeile JavaScript. +- **Übrige Konten (n)** ist ein Link: Er führt eine Ebene tiefer (`/?ebene=N`) und zeigt + die nächstkleineren Konten einzeln – beliebig tief, bis der Rest in die Palette passt. + Unter der Legende steht der Weg zurück. Jedes Konto erscheint auf genau einer Ebene, und + eine zu hohe Ebene fällt auf die letzte zurück (`Vermoegensentwicklung::ebenen()`). +- Die Balken nutzen die Kachelbreite aus, indem sie **auseinanderrücken**, nicht indem sie + breiter werden: Die Zeichenfläche rechnet waagerecht in Prozent, die Balkenbreite bleibt + bei 16 Punkten. Achsenbeschriftung und Zeichenfläche sind dafür zwei SVGs nebeneinander – + ein einzelnes könnte von seinen Prozentangaben keine feste Achsenbreite abziehen. +- Die Kachel ist so groß wie ihre Nachbarn: Der Ausgriff über zwei Rasterspalten + (`.kachel.breit`) und das engere Cockpit-Raster (`.kachelraster.dicht`) entfallen wieder. +- Die Legende steht unter der Zeichnung statt daneben und in kleinerer Schrift (.74rem); + sie füllt so viele Spalten, wie die Kachel hergibt. Der Schalter teilt sich die erste + Zeile mit der Überschrift, damit er in der schmaleren Kachel oben rechts bleibt. + ## Budgetplaner: drei Ansichten und Verteilen über das Zahnrad (11.08.2026) - Abgelaufene Monate werden **nicht mehr zusammengefasst**. Alle zwölf Monate stehen diff --git a/PLAN.md b/PLAN.md index 01f98b2..6472e7c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -269,8 +269,9 @@ Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag „Planung", „Steuern"): **Summenbericht, Buchungsbericht, Vergleichsbericht, Kontostandsbericht, Budgetbericht**, jeweils mit Zeitraum, Spalten-Aufteilung, Zeilen-Summe (Kategorie/Klasse/Konto/Empfänger), Filter auf Konten/Kategorien/Klassen. -* Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung, Ausgaben nach - Kategorie. +* Grafiken als SVG: Einnahmen/Ausgaben je Monat, Vermögensentwicklung *(erledigt: Cockpit- + Kachel mit den letzten zwölf Monaten, gestapelt je Konto — `docs/cockpit.md`)*, Ausgaben + nach Kategorie. * Export CSV; Drilldown von der Berichtszeile in die zugrunde liegenden Buchungen. * **Cockpit** mit konfigurierbaren Kacheln (Vermögensentwicklung, Einnahmen/Ausgaben, Meine Ausgaben, Kontostände, offene Vorgänge). diff --git a/README.md b/README.md index 2251181..be34ba7 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ Phase 0 bis 3 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log, Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen; Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für Stammdaten und Buchungen; Budgetplaner mit Monatswerten je Kategorie und der -Monatsabschluss samt Prüflauf, Snapshot und Sperre. Als Nächstes folgen nach `PLAN.md` -die Auswertungen und das Cockpit (Phase 4). +Monatsabschluss samt Prüflauf, Snapshot und Sperre. Aus Phase 4 steht die erste +Cockpit-Kachel: die Vermögensentwicklung der letzten zwölf Monate als gestapeltes +Balkendiagramm. Als Nächstes folgen nach `PLAN.md` die übrigen Auswertungen. ## Technik @@ -85,4 +86,5 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf. - `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen - `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden - `docs/planung.md` – Budgetplaner und Monatsabschluss +- `docs/cockpit.md` – Startseite und die Kachel Vermögensentwicklung - `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs diff --git a/docs/cockpit.md b/docs/cockpit.md new file mode 100644 index 0000000..20443f8 --- /dev/null +++ b/docs/cockpit.md @@ -0,0 +1,113 @@ +# Cockpit + +Die Startseite. Jede Kachel beantwortet eine Frage auf einen Blick; ausgebaut wird sie +Stück für Stück in Phase 4. + +## Vermögensentwicklung + +Ein gestapeltes Balkendiagramm der **Kontostände zum Monatsende** über die letzten zwölf +Monate – ein Balken je Monat, ein Farbton je Konto. + +### Woher die Zahlen kommen + +Welche Konten zählen, entscheidet allein der Schalter „nur aktive“. Das Σ-Kennzeichen +(`in_summe`) spielt hier **keine** Rolle – anders als in der Kontenübersicht. Stillgelegte +Konten tragen es meist nicht; würde es mitfiltern, fiele beim Ausschalten des Schalters +genau das weg, was man sehen will. Die Summe der Kachel kann bei ausgeschaltetem Schalter +deshalb von der Summe der Kontenübersicht abweichen. + +Der Stand eines Monats ist der Stand des Vormonats plus dessen Bewegungen: + +* `Buchung::saldenJeKonto()` liefert den Stand am Tag vor dem Zeitraum – alles Ältere + steckt damit im ersten Balken. +* `Buchung::bewegungenJeKontoUndMonat()` liefert die Bewegungen innerhalb des Zeitraums, + nach Monat getrennt. + +Zwei Abfragen statt zwölf Stichtagsabfragen. Beide Methoden zählen dieselben drei Quellen: +die Buchungen des Kontos, die Gegenseiten fremder Umbuchungen und die Gegenseiten aus +Splitzeilen. Eine Umbuchung verschiebt deshalb zwischen zwei Balkenstücken, ohne die Summe +zu ändern. + +Konten, die zwölf Monate lang auf null standen, fallen weg; sie zeichnen ohnehin keinen +Balken. + +### Der Schalter „nur aktive“ + +Oben rechts an der Kachel sitzt ein Ein/Aus-Schalter. Eingeschaltet – der Standard – zählen +nur Konten, die noch geführt werden. Ausgeschaltet kommen stillgelegte dazu: Sonst fehlte im +Rückblick Vermögen, das damals auf einem inzwischen aufgelösten Konto lag. + +Der Zustand liegt unter `cockpit_vermoegen_nur_aktive` in den Einstellungen und gilt bis zur +nächsten Umschaltung – auch über das Abmelden hinaus. Im Einstellungsdialog erscheint er +nicht: Er gehört an die Kachel, nicht in einen Reiter. + +Umgeschaltet wird über einen abschickenden Knopf, nicht über ein Ankreuzfeld. Ein +Ankreuzfeld könnte sein Formular nur mit einem Inline-Skript selbst abschicken, und die +Content-Security-Policy verbietet Inline-Skripte. + +### Stapel und Farben + +Positive Stände stapeln sich über der Nulllinie, negative darunter – ein überzogenes +Girokonto oder eine Kreditkarte zieht den Balken nach unten. + +Die Palette in `app.css` hat zwölf Töne (`.serie-1` bis `.serie-12`). Gibt es mehr Konten, +behalten die größten ihre eigene Farbe und der Rest wird zu **Übrige Konten (n)** +zusammengefasst – zwei Konten in derselben Farbe wären in der Legende nicht auseinander zu +halten. Jede Serie setzt `--farbe`; davon leben der Balken (`fill`) und der Legendenpunkt +(`background`) gleichermaßen, und beide folgen dem hellen wie dem dunklen Thema. + +### Ebenen: in „Übrige Konten“ hineinsehen + +Der Legendeneintrag **Übrige Konten (n)** ist ein Link. Er führt auf `/?ebene=1`: Dort +stehen die nächstelf Konten einzeln, und was dann noch bleibt, sammelt sich erneut. So +geht es beliebig tief, bis der Rest in die Palette passt – dann entfällt das Sammelstück, +und das ist die letzte Ebene. `Vermoegensentwicklung::ebenen()` rechnet aus, wie viele es +sind; eine zu hohe Ebene fällt auf die letzte zurück, statt eine leere Kachel zu zeigen. + +Zurück geht es über die Zeile unter der Legende: *eine Ebene zurück* und, ab der dritten, +*zu den größten Konten*. + +Jedes Konto steht auf genau einer Ebene: Was oben schon eine eigene Farbe hatte, ist im +Sammelstück nicht mehr enthalten. Sonst wüchse der Rest mit jeder Ebene, statt zu +schrumpfen. Weil auf tieferen Ebenen die großen Konten fehlen, passt sich die Achse an – +die kleinen Konten werden dadurch überhaupt erst sichtbar. Die Ebene steht in der Adresse +und nicht in den Einstellungen: Beim nächsten Aufruf beginnt man wieder oben. + +### SVG von Hand + +Keine Diagrammbibliothek (siehe `PLAN.md`): `src/Views/cockpit/vermoegensdiagramm.php` +schreibt das SVG selbst. Farben stehen als Klasse am Element, nicht als `fill`-Attribut – +ein Attribut käme zwar an der Content-Security-Policy vorbei, könnte aber nicht auf die +Themenumschaltung reagieren. Ein `style`-Attribut verwürfe die CSP ohnehin. + +Die Achse rundet über `Vermoegensentwicklung::skala()` auf Schritte von 1, 2, 2,5 oder 5 +mal einer Zehnerpotenz und schließt die Null immer ein. Jedes Balkenstück trägt ein +`<title>` mit Konto, Monat und Betrag – das ist der Tooltip, ohne eine Zeile JavaScript. + +Senkrecht rechnet die Zeichnung in Bildpunkten, waagerecht in Prozent: Die Balkenmitte von +Monat *i* liegt bei `(i + 0,5) / 12` der Fläche, der Balken selbst ist feste 16 Punkte breit +und wird per `transform` um seine halbe Breite nach links gerückt. In einer breiten Kachel +rücken die Balken damit **auseinander**, statt breiter zu werden. + +Deshalb sind es zwei SVGs nebeneinander (`.diagrammflaeche`): links die Achsenbeschriftung +mit fester Breite, rechts die Zeichenfläche. In einem einzigen SVG ginge das nicht – +Prozentangaben rechnen immer auf die volle Breite, und ein Attribut kann davon keine feste +Achsenbreite abziehen. Ein `viewBox` verbietet sich aus demselben Grund: Es würde alles +gleichmäßig strecken, die Balken eingeschlossen. + +Die Kachel ist eine gewöhnliche Kachel des Cockpit-Rasters, so breit wie ihre Nachbarn: +Ein Ausgriff über zwei Spalten machte sie zum Fremdkörper zwischen den anderen dreien. + +Die Legende führt nur Farbe und Kontoname und steht darunter, in kleinerer Schrift und so +vielen Spalten, wie die Kachel hergibt (`auto-fill`). Die Beträge stehen am Balken im +Tooltip und in der Kachel *Kontostände*; in der Legende kosteten sie nur Breite. + +## Tests + +* `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände, + Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette, + Ebenen (jedes Konto genau einmal, letzte Ebene ohne Sammelstück, zu tiefe Ebene fällt + zurück), Skala +* `tests/e2e/specs/smoke.spec.js` – die Kachel zeichnet zwölf Balken, keine Farbe der + Legende kommt doppelt vor, der Schalter behält seinen Zustand über den Neuaufruf, und + *Übrige Konten* führt eine Ebene tiefer und wieder zurück diff --git a/public/index.php b/public/index.php index 55c8249..be62498 100644 --- a/public/index.php +++ b/public/index.php @@ -55,6 +55,7 @@ $router->get('/einrichtung', fn() => (new LoginController())->einrichtungFormul $router->post('/einrichtung', fn() => (new LoginController())->einrichtungSpeichern()); $router->get('/', fn() => (new CockpitController())->index()); +$router->post('/cockpit/vermoegen/konten', fn() => (new CockpitController())->kontenfilter()); $router->get('/einstellungen', fn() => (new EinstellungenController())->index()); $router->post('/einstellungen', fn() => (new EinstellungenController())->speichern()); diff --git a/public/static/app.css b/public/static/app.css index 99da9e1..3599ee2 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -276,6 +276,27 @@ form.inline { display: inline; margin: 0; } .kachel header { margin-bottom: .6rem; } +.kachel header.mitschalter { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem 1rem; + flex-wrap: wrap; +} + +/* Der Schalter teilt sich die Zeile mit der Überschrift und bleibt oben rechts; + der erklärende Satz nimmt die ganze nächste Zeile. Stünde er in derselben + Zeile, drückte er den Schalter in einer schmalen Kachel nach unten. */ +.kachel header.mitschalter > h2 { min-width: 0; } + +.kachel header.mitschalter > .kachelhinweis { flex: 1 0 100%; margin: 0; } + +.kachelhinweis { + margin: .25rem 0 0; + color: var(--text-gedaempft); + font-size: .85rem; +} + .kachel h2 { margin: 0; font-size: 1rem; @@ -321,6 +342,184 @@ form.inline { display: inline; margin: 0; } .checkliste li.erledigt { color: var(--text); } .checkliste li.erledigt::before { content: "●"; color: var(--positiv); } +/* ── Diagramme ─────────────────────────────────────────────────────────────── + + Selbst erzeugtes SVG, keine Diagrammbibliothek. Die Farbe eines Kontos steht + als Klasse am Element: Ein fill-Attribut ginge zwar an der CSP vorbei, könnte + aber nicht auf das dunkle Thema reagieren. Jede Serie setzt --farbe, davon + leben Balken (fill) und Legendenpunkt (background) gleichermaßen. */ +.serie-1 { --farbe: #4c78a8; } +.serie-2 { --farbe: #f58518; } +.serie-3 { --farbe: #54a24b; } +.serie-4 { --farbe: #e45756; } +.serie-5 { --farbe: #72b7b2; } +.serie-6 { --farbe: #b279a2; } +.serie-7 { --farbe: #d8b02c; } +.serie-8 { --farbe: #9d755d; } +.serie-9 { --farbe: #2f8f8a; } +.serie-10 { --farbe: #8f8ad4; } +.serie-11 { --farbe: #e07b91; } +.serie-12 { --farbe: #7b8a99; } + +.diagrammbereich { + display: flex; + flex-direction: column; + gap: .6rem; +} + +/* Achsenbeschriftung und Zeichenfläche sind zwei SVGs nebeneinander. Die Achse + ist fest, die Fläche nimmt den Rest – und weil deren Balken waagerecht in + Prozent sitzen, rücken sie in einer breiten Kachel auseinander, statt breiter + zu werden. Ein einziges SVG könnte das nicht: Prozentangaben rechnen immer + auf die volle Breite, die feste Achse ließe sich nicht abziehen. */ +.diagrammflaeche { + display: flex; + align-items: flex-start; + width: 100%; +} + +.diagrammachse { flex: none; } + +.diagramm { + flex: 1 1 0; + min-width: 0; + display: block; + overflow: visible; +} + +.diagramm rect { fill: var(--farbe); } + +.diagramm-gitter { + stroke: var(--rand); + stroke-width: 1; +} + +.diagramm-nulllinie { + stroke: var(--rand-kraeftig); + stroke-width: 1.5; +} + +.diagramm-achse, +.diagramm-beschriftung { + fill: var(--text-gedaempft); + font-family: var(--schrift); + font-size: 10px; +} + +/* Ziffernschrift für die Achse, aber schmal genug für 21 Punkte Spaltenabstand + bleiben die Monatskürzel darunter. */ +.diagramm-achse { + font-family: var(--schrift-zahl); + font-size: 11px; +} + +.diagramm-beschriftung.schwach { opacity: .7; } + +/* Unter der Zeichnung und in kleiner Schrift: So bleibt die Kachel so breit wie + ihre Nachbarn, und zwölf Kontonamen passen in zwei Spalten darunter. */ +.diagrammlegende { + list-style: none; + margin: 0; + padding: 0; + align-self: stretch; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); + gap: 0 1rem; + font-size: .74rem; + align-content: start; +} + +.diagrammlegende li { + display: flex; + align-items: center; + gap: .4rem; + padding: .1rem 0; +} + +.legendenfarbe { + width: .65rem; + height: .65rem; + border-radius: 2px; + background: var(--farbe); + flex: none; +} + +.legendenname { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* „Übrige Konten“ führt eine Ebene tiefer: Der Eintrag ist ein Link und sieht + auch danach aus – sonst probierte ihn niemand aus. */ +.legendenname.tiefer { + color: var(--akzent); + text-decoration: underline dotted; + text-underline-offset: 2px; +} + +.legendenname.tiefer:hover { text-decoration: underline; } + +.diagrammpfad { + margin: 0; + font-size: .74rem; + color: var(--text-gedaempft); +} + +/* ── Ein/Aus-Schalter ──────────────────────────────────────────────────────── + + Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein + abschickender Knopf statt eines Ankreuzfelds: Ohne Inline-Skript könnte ein + Ankreuzfeld sein Formular nicht selbst abschicken, und Inline-Skripte + verbietet die Content-Security-Policy. */ +.schalterform { margin: 0; } + +.schalter { + display: inline-flex; + align-items: center; + gap: .5rem; + padding: .15rem; + border: none; + border-radius: var(--radius); + background: none; + color: var(--text-gedaempft); + font: inherit; + font-size: .85rem; + cursor: pointer; +} + +.schalter[aria-checked="true"] { color: var(--text); } + +.schalter:focus-visible { + outline: 2px solid var(--akzent); + outline-offset: 1px; +} + +.schalterbahn { + position: relative; + flex: none; + width: 2.3rem; + height: 1.25rem; + border-radius: 999px; + background: var(--rand-kraeftig); + transition: background .15s ease; +} + +.schalterknopf { + position: absolute; + top: .15rem; + left: .15rem; + width: .95rem; + height: .95rem; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / 35%); + transition: transform .15s ease; +} + +.schalter[aria-checked="true"] .schalterbahn { background: var(--akzent); } +.schalter[aria-checked="true"] .schalterknopf { transform: translateX(1.05rem); } + /* ── Tabellen ────────────────────────────────────────────────────────────── */ table.liste { diff --git a/src/Controllers/CockpitController.php b/src/Controllers/CockpitController.php index 5feb25b..c5956d0 100644 --- a/src/Controllers/CockpitController.php +++ b/src/Controllers/CockpitController.php @@ -4,19 +4,55 @@ declare(strict_types=1); namespace Finanzmanager\Controllers; +use Finanzmanager\Models\Einstellung; use Finanzmanager\Models\Konto; +use Finanzmanager\Services\Vermoegensentwicklung; +use Finanzmanager\View; class CockpitController extends BaseController { + /** + * Der Schalter „nur aktive“ an der Vermögenskachel. Er merkt sich seinen + * Zustand über die Einstellungen und nicht in der Sitzung: Er soll auch beim + * nächsten Anmelden noch so stehen, wie der Nutzer ihn gelassen hat. Im + * Einstellungsdialog taucht er nicht auf – er gehört an die Kachel. + */ + public const SCHALTER_NUR_AKTIVE = 'cockpit_vermoegen_nur_aktive'; + public function index(): void { $this->verlangeAnmeldung(); - // Die Kontostandskachel zeigt schon jetzt echte Zahlen; die übrigen - // Kacheln brauchen Buchungen und füllen sich ab Phase 2. + $nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true); + + // Die Ebene steht in der Adresse, nicht in den Einstellungen: Wer in die + // „Übrigen Konten“ hineinsieht, will beim nächsten Aufruf wieder oben + // anfangen – und kann den Blick trotzdem als Lesezeichen ablegen. + $ebene = max(0, (int)$this->query('ebene')); + $this->render('cockpit/index.php', [ 'titel' => 'Cockpit', 'uebersicht' => Konto::uebersicht(), + 'vermoegen' => Vermoegensentwicklung::reihe( + $nurAktive, + Vermoegensentwicklung::MONATE, + null, + $ebene + ), + 'nurAktive' => $nurAktive, ]); } + + public function kontenfilter(): void + { + $this->verlangeAnmeldung(); + + Einstellung::set( + self::SCHALTER_NUR_AKTIVE, + $this->post('nur_aktive') === '1' ? '1' : '0', + 'darstellung' + ); + + View::redirect('/'); + } } diff --git a/src/Models/Buchung.php b/src/Models/Buchung.php index bd8977b..d589c70 100644 --- a/src/Models/Buchung.php +++ b/src/Models/Buchung.php @@ -257,6 +257,51 @@ class Buchung return $salden; } + /** + * Dieselben drei Quellen wie saldenJeKonto(), aber nach Monat getrennt und + * auf einen Zeitraum begrenzt. Für die Vermögensentwicklung: Ein Stichtag je + * Monat wären zwölf Abfragen, so genügt eine je Quelle. + * + * @return array<int, array<string, float>> konto_id => ['JJJJ-MM' => Bewegung] + */ + public static function bewegungenJeKontoUndMonat(string $von, string $bis): array + { + $benutzer = Mandant::id(); + $p = [$benutzer, $von, $bis]; + + $quellen = [ + "SELECT b.konto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, SUM(b.betrag) AS s + FROM buchungen b + WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? + GROUP BY b.konto_id, m", + + "SELECT b.gegenkonto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, -SUM(b.betrag) AS s + FROM buchungen b + WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? + AND b.gegenkonto_id IS NOT NULL AND b.gesplittet = 0 + GROUP BY b.gegenkonto_id, m", + + "SELECT s.gegenkonto_id AS k, DATE_FORMAT(b.datum, '%Y-%m') AS m, -SUM(s.betrag) AS s + FROM buchung_splits s + JOIN buchungen b ON b.id = s.buchung_id + WHERE b.benutzer_id = ? AND b.datum BETWEEN ? AND ? + AND s.gegenkonto_id IS NOT NULL + GROUP BY s.gegenkonto_id, m", + ]; + + $bewegungen = []; + foreach ($quellen as $sql) { + foreach (Database::fetchAll($sql, $p) as $zeile) { + $id = (int)$zeile['k']; + $monat = (string)$zeile['m']; + + $bewegungen[$id][$monat] = ($bewegungen[$id][$monat] ?? 0.0) + (float)$zeile['s']; + } + } + + return $bewegungen; + } + /** Wie viele Buchungen hat das Konto (beide Sichten)? */ public static function anzahlFuerKonto(int $kontoId): int { diff --git a/src/Services/Vermoegensentwicklung.php b/src/Services/Vermoegensentwicklung.php new file mode 100644 index 0000000..4a521de --- /dev/null +++ b/src/Services/Vermoegensentwicklung.php @@ -0,0 +1,252 @@ +<?php + +declare(strict_types=1); + +namespace Finanzmanager\Services; + +use Finanzmanager\Models\Buchung; +use Finanzmanager\Models\Konto; + +/** + * Die Kontostände der letzten zwölf Monate, je Konto und Monatsende – Grundlage + * der Cockpit-Kachel *Vermögensentwicklung*. + * + * Einziges Kriterium ist der Schalter „nur aktive“ an der Kachel: eingeschaltet + * zählen die geführten Konten, ausgeschaltet alle. Das Σ-Kennzeichen (`in_summe`) + * bleibt hier ausdrücklich außen vor – stillgelegte Konten tragen es meist nicht, + * und dann fiele beim Ausschalten genau das weg, was man sehen will. + */ +class Vermoegensentwicklung +{ + public const MONATE = 12; + + /** So viele Farbtöne hat die Palette in `app.css`. */ + public const SERIEN = 12; + + /** + * @param int $ebene 0 zeigt die größten Konten, jede weitere Ebene die + * jeweils nächstkleineren aus „Übrige Konten“. + * + * @return array{ + * monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>, + * konten: list<array{id:int, name:string, werte:list<float>}>, + * summen: list<float>, + * hatWerte: bool, + * ebene: int, + * ebenen: int + * } + */ + public static function reihe( + bool $nurAktive = true, + int $anzahl = self::MONATE, + ?string $heute = null, + int $ebene = 0 + ): array { + $monate = self::monate($anzahl, $heute); + $erster = $monate[0]; + $letzter = $monate[count($monate) - 1]; + + // Alles vor dem Zeitraum steckt im Anfangsstand; innerhalb zählt Monat + // für Monat dazu. So genügen zwei Abfragen statt einer je Stichtag. + $vorlauf = date('Y-m-d', strtotime($erster['bis'] . ' first day of this month -1 day')); + + $anfang = Buchung::saldenJeKonto($vorlauf); + $bewegungen = Buchung::bewegungenJeKontoUndMonat( + date('Y-m-01', strtotime($erster['bis'])), + $letzter['bis'] + ); + + $konten = []; + $summen = array_fill(0, count($monate), 0.0); + + foreach (Konto::alle($nurAktive) as $konto) { + $id = (int)$konto['id']; + $stand = (float)$konto['anfangssaldo'] + ($anfang[$id] ?? 0.0); + $werte = []; + $bewegt = false; + + foreach ($monate as $nummer => $monat) { + $schluessel = sprintf('%04d-%02d', $monat['jahr'], $monat['monat']); + $stand += $bewegungen[$id][$schluessel] ?? 0.0; + $werte[] = round($stand, 2); + + $summen[$nummer] += $stand; + $bewegt = $bewegt || round($stand, 2) !== 0.0; + } + + // Konten, die im ganzen Zeitraum auf null standen, blähen nur die + // Legende auf – sie zeichnen ohnehin keinen Balken. + if ($bewegt) { + $konten[] = ['id' => $id, 'name' => (string)$konto['name'], 'werte' => $werte]; + } + } + + $ebenen = self::ebenen(count($konten)); + $ebene = max(0, min($ebene, $ebenen - 1)); + + if (count($konten) > self::SERIEN) { + $konten = self::zusammenfassen($konten, count($monate), $ebene); + } + + $hatWerte = false; + foreach ($summen as $nummer => $summe) { + $summen[$nummer] = round($summe, 2); + $hatWerte = $hatWerte || $summen[$nummer] !== 0.0; + } + + return [ + 'monate' => $monate, + 'konten' => $konten, + 'summen' => array_values($summen), + 'hatWerte' => $hatWerte || $konten !== [], + 'ebene' => $ebene, + 'ebenen' => $ebenen, + ]; + } + + /** + * Wie viele Ebenen es braucht, um `$konten` Konten einzeln zu zeigen: Jede + * Ebene zeigt so viele Konten, wie Farbtöne da sind – eine weniger, wenn + * noch ein Stück „Übrige Konten“ dazukommt. + */ + public static function ebenen(int $konten): int + { + if ($konten <= self::SERIEN) { + return 1; + } + + return 1 + (int)ceil(($konten - self::SERIEN) / (self::SERIEN - 1)); + } + + /** + * Mehr Konten als Farbtöne: Die größten behalten ihre eigene Farbe, der Rest + * wird zu einem Balkenstück zusammengefasst. Sonst bekämen zwei Konten + * denselben Ton – und wer die Legende liest, ordnete falsch zu. + * + * `$ebene` blättert in diesem Rest weiter: Ebene 1 überspringt die größten + * Konten und zeigt die nächstkleineren einzeln, Ebene 2 die darauf folgenden + * und so fort. Passt der Rest in die Palette, entfällt das Sammelstück. + * + * @param list<array{id:int, name:string, werte:list<float>}> $konten + * @return list<array{id:int, name:string, werte:list<float>}> + */ + private static function zusammenfassen(array $konten, int $spalten, int $ebene = 0): array + { + $groesse = []; + foreach ($konten as $nummer => $konto) { + $groesse[$nummer] = max(array_map('abs', $konto['werte'])); + } + arsort($groesse); + + $rangfolge = array_slice(array_keys($groesse), $ebene * (self::SERIEN - 1)); + + // Passt der Rest vollständig in die Palette, braucht es kein Sammelstück + // mehr – dann ist das die letzte Ebene. + $sammelt = count($rangfolge) > self::SERIEN; + + // Die größten behalten, aber in ihrer ursprünglichen Reihenfolge – der + // Stapel soll nicht bei jedem Aufruf anders sortiert sein. + $eigene = $sammelt ? array_slice($rangfolge, 0, self::SERIEN - 1) : $rangfolge; + sort($eigene); + + $rest = array_fill(0, $spalten, 0.0); + $restKonten = 0; + $ergebnis = []; + + foreach ($konten as $nummer => $konto) { + if (in_array($nummer, $eigene, true)) { + $ergebnis[] = $konto; + continue; + } + + // Was auf einer früheren Ebene stand, ist hier gar nicht mehr dabei – + // sonst wüchse das Sammelstück mit jeder Ebene statt zu schrumpfen. + if (!in_array($nummer, $rangfolge, true)) { + continue; + } + + foreach ($konto['werte'] as $spalte => $wert) { + $rest[$spalte] += $wert; + } + $restKonten++; + } + + if ($restKonten > 0) { + $ergebnis[] = [ + 'id' => 0, + 'name' => 'Übrige Konten (' . $restKonten . ')', + 'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest), + ]; + } + + return $ergebnis; + } + + /** + * Die letzten `$anzahl` Monate bis einschließlich des laufenden, jeweils mit + * dem Monatsletzten als Stichtag. + * + * @return list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}> + */ + public static function monate(int $anzahl = self::MONATE, ?string $heute = null): array + { + $heute = $heute ?? date('Y-m-d'); + $start = strtotime(substr($heute, 0, 8) . '01'); + + $monate = []; + for ($rueckwaerts = $anzahl - 1; $rueckwaerts >= 0; $rueckwaerts--) { + $zeit = strtotime('-' . $rueckwaerts . ' month', $start); + $jahr = (int)date('Y', $zeit); + $nr = (int)date('n', $zeit); + + $monate[] = [ + 'jahr' => $jahr, + 'monat' => $nr, + 'bis' => date('Y-m-t', $zeit), + 'kuerzel' => Budgetplaner::MONATSKUERZEL[$nr], + 'jahrKurz' => date('y', $zeit), + ]; + } + + return $monate; + } + + /** + * Skala für die Achse: Nulllinie inbegriffen, Schritte in 1/2/2,5/5 mal + * Zehnerpotenz – die Beschriftung soll lesbare Zahlen zeigen, nicht die + * krummen Maxima der Konten. + * + * @param list<float> $werte + * @return array{min: float, max: float, schritt: float} + */ + public static function skala(array $werte): array + { + $max = max(0.0, ...($werte === [] ? [0.0] : $werte)); + $min = min(0.0, ...($werte === [] ? [0.0] : $werte)); + + if ($max - $min < 0.01) { + return ['min' => 0.0, 'max' => 1.0, 'schritt' => 1.0]; + } + + $schritt = self::schritt(($max - $min) / 4); + + return [ + 'min' => floor($min / $schritt) * $schritt, + 'max' => ceil($max / $schritt) * $schritt, + 'schritt' => $schritt, + ]; + } + + private static function schritt(float $roh): float + { + $potenz = 10 ** floor(log10(max($roh, 0.01))); + + foreach ([1, 2, 2.5, 5, 10] as $faktor) { + if ($roh <= $faktor * $potenz) { + return $faktor * $potenz; + } + } + + return 10 * $potenz; + } +} diff --git a/src/Views/cockpit/index.php b/src/Views/cockpit/index.php index a43985a..d8ba280 100644 --- a/src/Views/cockpit/index.php +++ b/src/Views/cockpit/index.php @@ -3,6 +3,9 @@ use Finanzmanager\View; /** @var array{gruppen: list<array<string,mixed>>, summe: float} $uebersicht */ +/** @var array{monate: list<array<string,mixed>>, konten: list<array<string,mixed>>, summen: list<float>, hatWerte: bool} $vermoegen */ +/** @var bool $nurAktive */ +/** @var string $csrfToken */ include __DIR__ . '/../layout/kopf.php'; ?> @@ -13,9 +16,33 @@ include __DIR__ . '/../layout/kopf.php'; <div class="kachelraster"> <section class="kachel"> - <header><h2>Vermögensentwicklung</h2></header> - <p class="platzhalter">Zeigt Kontostände über die Zeit, sobald Buchungen - erfasst sind (Phase 2).</p> + <header class="mitschalter"> + <h2>Vermögensentwicklung</h2> + + <?php /* Ein Knopf, kein Ankreuzfeld: Ohne Inline-Skript könnte ein + Ankreuzfeld das Formular nicht selbst abschicken, und die + Content-Security-Policy verbietet Inline-Skripte. */ ?> + <form method="post" action="/cockpit/vermoegen/konten" class="schalterform"> + <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> + <input type="hidden" name="nur_aktive" value="<?= $nurAktive ? '0' : '1' ?>"> + <button type="submit" class="schalter" role="switch" + aria-checked="<?= $nurAktive ? 'true' : 'false' ?>"> + <span class="schalterbahn" aria-hidden="true"><span class="schalterknopf"></span></span> + <span class="schaltertext">nur aktive</span> + </button> + </form> + + <p class="kachelhinweis">Kontostände der letzten zwölf Monate, je Monatsende + gestapelt – ein Farbton pro Konto.</p> + </header> + <?php if (!$vermoegen['hatWerte']): ?> + <p class="platzhalter"> + Zeigt die Kontostände über die Zeit, sobald <?= $nurAktive ? 'aktive ' : '' ?>Konten + mit Σ-Kennzeichen Buchungen haben. + </p> + <?php else: ?> + <?php $reihe = $vermoegen; include __DIR__ . '/vermoegensdiagramm.php'; ?> + <?php endif; ?> </section> <section class="kachel"> @@ -66,7 +93,7 @@ include __DIR__ . '/../layout/kopf.php'; <li class="erledigt">Phase 0 – Grundgerüst, Anmeldung, Einstellungen</li> <li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li> <li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li> - <li>Phase 3 – Budgetplaner und Monatsabschluss</li> + <li class="erledigt">Phase 3 – Budgetplaner und Monatsabschluss</li> <li>Phase 4 – Auswertungen und Cockpit-Kacheln</li> </ul> </section> diff --git a/src/Views/cockpit/vermoegensdiagramm.php b/src/Views/cockpit/vermoegensdiagramm.php new file mode 100644 index 0000000..1c0e3e5 --- /dev/null +++ b/src/Views/cockpit/vermoegensdiagramm.php @@ -0,0 +1,175 @@ +<?php + +use Finanzmanager\Services\Vermoegensentwicklung; +use Finanzmanager\View; + +/** + * Gestapeltes Balkendiagramm der Kontostände je Monatsende – ein Balken je + * Monat, ein Farbton je Konto. + * + * Eigenes SVG statt einer Diagrammbibliothek (siehe PLAN.md). Die Farben stehen + * als Klasse am Element und kommen aus `app.css`: Ein `fill`-Attribut je Konto + * ginge zwar, verlöre aber die Themenumschaltung, und ein `style`-Attribut + * verwürfe die Content-Security-Policy ohnehin. + * + * @var array{ + * monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>, + * konten: list<array{id:int, name:string, werte:list<float>}>, + * summen: list<float>, + * hatWerte: bool, + * ebene: int, + * ebenen: int + * } $reihe + */ + +$monate = $reihe['monate']; +$konten = $reihe['konten']; +$anzahl = count($monate); +$ebene = $reihe['ebene']; +$ebenen = $reihe['ebenen']; + +// Die Achse muss den gestapelten Positiv- und Negativteil jedes Balkens fassen. +// Die Nettosumme liegt immer dazwischen und braucht keine eigene Grenze. +$grenzwerte = []; +foreach ($monate as $nummer => $monat) { + $plus = 0.0; + $minus = 0.0; + foreach ($konten as $konto) { + $wert = $konto['werte'][$nummer]; + if ($wert >= 0) { + $plus += $wert; + } else { + $minus += $wert; + } + } + $grenzwerte[] = $plus; + $grenzwerte[] = $minus; +} + +$skala = Vermoegensentwicklung::skala($grenzwerte); + +// Senkrecht in echten Bildpunkten, waagerecht in Prozent: Die Zeichenfläche +// nimmt die Breite, die die Kachel hergibt, und die Balken rücken darin +// auseinander, statt breiter zu werden. Die Achsenbeschriftung sitzt in einem +// eigenen, festen SVG daneben – sonst müsste die Prozentrechnung ihre Breite +// abziehen, und das kann ein SVG-Attribut nicht. +$balken = 16; +$achse = 56; +$oben = 10; +$plotH = 150; +$unten = 32; +$hoehe = $oben + $plotH + $unten; + +$y = static function (float $wert) use ($skala, $oben, $plotH): float { + return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2); +}; + +/** Die Mitte einer Monatsspalte, als Anteil der Zeichenfläche. */ +$mitte = static function (int $nummer) use ($anzahl): string { + return round(($nummer + 0.5) / $anzahl * 100, 3) . '%'; +}; + +$achsenzahl = static function (float $wert): string { + return number_format($wert, 0, ',', '.'); +}; + +$serie = static function (int $nummer): string { + return 'serie-' . ($nummer % 12 + 1); +}; + +$zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz'] + . ' bis ' . $monate[$anzahl - 1]['kuerzel'] . ' ' . $monate[$anzahl - 1]['jahrKurz']; +?> + +<div class="diagrammbereich"> +<div class="diagrammflaeche"> + <svg class="diagrammachse" width="<?= $achse ?>" height="<?= $hoehe ?>" aria-hidden="true"> + <?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?> + <text class="diagramm-achse" x="<?= $achse - 8 ?>" y="<?= $y($wert) + 4 ?>" + text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text> + <?php endfor; ?> + </svg> + + <?php /* Ohne viewBox: Die Koordinaten bleiben Bildpunkte, nur die + Prozentangaben wachsen mit der Fläche. */ ?> + <svg class="diagramm" height="<?= $hoehe ?>" role="img" + aria-label="Kontostände je Monatsende, <?= View::h($zeitraum) ?>"> + <title>Vermögensentwicklung <?= View::h($zeitraum) ?> + + + + + + + + $monat): ?> + + + + $konto): ?> + 0) { + $von = $oberkante; + $bis = $oberkante + $wert; + $oberkante = $bis; + } else { + $von = $unterkante + $wert; + $bis = $unterkante; + $unterkante = $von; + } + ?> + + <?= View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr'] + . ': ' . View::betrag($wert)) ?> + + + + + + + + + + + + + + + 0): ?> +

+ Ebene von · + ← eine Ebene zurück + 1): ?> + · zu den größten Konten + +

+ + diff --git a/tests/13_VermoegensentwicklungTest.php b/tests/13_VermoegensentwicklungTest.php new file mode 100644 index 0000000..1880b81 --- /dev/null +++ b/tests/13_VermoegensentwicklungTest.php @@ -0,0 +1,313 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->zwoelfMonateBisHeute(); + $this->staendeWachsenMonatFuerMonat(); + $this->umbuchungVerschiebtZwischenKonten(); + $this->summenkennzeichenFiltertNicht(); + $this->schalterFiltertStillgelegteKonten(); + $this->mehrKontenAlsFarbenWerdenZusammengefasst(); + $this->ebenenBlaetternDurchDieUebrigenKonten(); + $this->skalaRundetAufLesbareSchritte(); + } + + private function stammdaten(): void + { + $this->giro = Konto::anlegen([ + 'name' => 'Vermögensgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1, + ]); + $this->sparkonto = Konto::anlegen([ + 'name' => 'Vermögenssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->kreditkarte = Konto::anlegen([ + 'name' => 'Vermögenskarte', 'kontoart' => 'kreditkarte', 'anfangssaldo' => -200, 'aktiv' => 1, + ]); + // Durchlaufende Auslagen sind kein Vermögen und tragen kein Σ-Kennzeichen. + $this->auslagen = Konto::anlegen([ + 'name' => 'Vermögensauslagen', 'kontoart' => 'sonstiges', 'anfangssaldo' => 5000, + 'aktiv' => 1, 'in_summe' => 0, + ]); + + $this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe'); + } + + private function reihe(bool $nurAktive = true, int $ebene = 0): array + { + return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE, $ebene); + } + + /** @return array|null */ + private function konto(array $reihe, int $id): ?array + { + foreach ($reihe['konten'] as $konto) { + if ($konto['id'] === $id) { + return $konto; + } + } + + return null; + } + + private function zwoelfMonateBisHeute(): void + { + $monate = Vermoegensentwicklung::monate(12, self::HEUTE); + + $this->assertEquals(12, count($monate), 'Zwölf Monate'); + $this->assertEquals(2025, $monate[0]['jahr'], 'Der Rückblick beginnt im Vorjahr'); + $this->assertEquals(9, $monate[0]['monat'], 'Zwölf Monate vor August 2026 ist September 2025'); + $this->assertEquals(2026, $monate[11]['jahr'], 'Der letzte Balken ist der laufende Monat'); + $this->assertEquals(8, $monate[11]['monat'], 'August 2026'); + $this->assertEquals('2026-08-31', $monate[11]['bis'], 'Stichtag ist das Monatsende'); + $this->assertEquals('2026-02-28', $monate[5]['bis'], 'Auch im Februar das echte Monatsende'); + $this->assertEquals('Sep', $monate[0]['kuerzel'], 'Monatskürzel für die Achse'); + } + + /** + * Der Stand eines Monats ist der Stand des Vormonats plus dessen Bewegungen – + * und Buchungen vor dem Zeitraum stecken im Anfangsstand des ersten Balkens. + */ + private function staendeWachsenMonatFuerMonat(): void + { + // Lange vor dem Zeitraum: zählt in den ersten Balken hinein. + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2024-05-10', 'betrag' => 500, + 'empfaenger' => 'Altbestand', 'kategorie_id' => $this->kategorie, + ]); + // Im Zeitraum: hebt den Stand ab Dezember 2025. + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2025-12-05', 'betrag' => 300, + 'empfaenger' => 'Zufluss', 'kategorie_id' => $this->kategorie, + ]); + // Nach dem letzten Balken: bleibt außen vor. + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-11-01', 'betrag' => 9999, + 'empfaenger' => 'Zukunft', 'kategorie_id' => $this->kategorie, + ]); + + $reihe = $this->reihe(); + $giro = $this->konto($reihe, $this->giro); + + $this->assertNotEmpty($giro, 'Das Girokonto hat einen Balken'); + $this->assertEquals(1500.0, $giro['werte'][0], 'September 2025: Anfangssaldo plus Altbestand'); + $this->assertEquals(1500.0, $giro['werte'][2], 'November 2025 unverändert'); + $this->assertEquals(1800.0, $giro['werte'][3], 'Dezember 2025 mit dem Zufluss'); + $this->assertEquals(1800.0, $giro['werte'][11], 'Der Stand trägt bis in den letzten Monat'); + + // Die Kreditkarte steht im Minus – sie zieht die Summe herunter. + $karte = $this->konto($reihe, $this->kreditkarte); + $this->assertEquals(-200.0, $karte['werte'][11], 'Negativer Stand bleibt negativ'); + $this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen'); + + // Giro 1800, Kreditkarte -200, Auslagen 5000, Sparbuch 0. + $this->assertEquals(6600.0, $reihe['summen'][11], 'Die Summe ist der Stapel aller Konten'); + } + + private function umbuchungVerschiebtZwischenKonten(): void + { + Buchung::anlegen([ + 'konto_id' => $this->giro, 'datum' => '2026-03-15', 'betrag' => -400, + 'empfaenger' => 'Umbuchung aufs Sparbuch', 'gegenkonto_id' => $this->sparkonto, + ]); + + $reihe = $this->reihe(); + $giro = $this->konto($reihe, $this->giro); + $spar = $this->konto($reihe, $this->sparkonto); + + $this->assertEquals(1800.0, $giro['werte'][5], 'Februar 2026 noch ohne Umbuchung'); + $this->assertEquals(1400.0, $giro['werte'][6], 'März 2026: 400 weniger auf dem Giro'); + $this->assertEquals(400.0, $spar['werte'][6], 'Dieselben 400 liegen auf dem Sparbuch'); + $this->assertEquals( + $reihe['summen'][5], + $reihe['summen'][6], + 'Eine Umbuchung ändert das Vermögen nicht' + ); + } + + /** + * Das Σ-Kennzeichen entscheidet hier nichts: Stillgelegte Konten tragen es + * meist nicht, und dann fiele beim Ausschalten des Schalters genau das weg, + * was man sehen will. + */ + private function summenkennzeichenFiltertNicht(): void + { + $reihe = $this->reihe(); + + $this->assertNotEmpty( + $this->konto($reihe, $this->auslagen), + 'Ein aktives Konto ohne Σ-Kennzeichen zeichnet trotzdem einen Balken' + ); + + // Ein Konto, das im ganzen Zeitraum auf null steht, bläht nur die Legende auf. + $leer = Konto::anlegen([ + 'name' => 'Vermögensleerkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->assertNull($this->konto($this->reihe(), $leer), 'Ein durchgehend leeres Konto fehlt'); + } + + /** + * Der Schalter „nur aktive“ an der Kachel: eingeschaltet zählt nur, was noch + * geführt wird; ausgeschaltet auch das Vermögen auf stillgelegten Konten. + */ + private function schalterFiltertStillgelegteKonten(): void + { + $stillgelegt = Konto::anlegen([ + 'name' => 'Vermögensaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 2500, 'aktiv' => 0, + ]); + + $this->assertNull( + $this->konto($this->reihe(), $stillgelegt), + 'Eingeschaltet bleibt das stillgelegte Konto draußen' + ); + + $alle = $this->reihe(false); + $this->assertNotEmpty($this->konto($alle, $stillgelegt), 'Ausgeschaltet ist es dabei'); + $this->assertEquals( + 2500.0, + $this->konto($alle, $stillgelegt)['werte'][11], + 'Mit seinem Stand' + ); + $this->assertEquals( + round($this->reihe()['summen'][11] + 2500.0, 2), + $alle['summen'][11], + 'Und die Summe wächst um genau diesen Stand' + ); + } + + /** + * Zwölf Farbtöne hat die Palette; darüber hinaus behalten die größten Konten + * ihre Farbe, der Rest wird zu einem Stück zusammengefasst. + */ + private function mehrKontenAlsFarbenWerdenZusammengefasst(): void + { + for ($nummer = 1; $nummer <= 15; $nummer++) { + Konto::anlegen([ + 'name' => 'Vermögensklein ' . $nummer, + 'kontoart' => 'bank', + 'anfangssaldo' => $nummer, + 'aktiv' => 1, + ]); + } + + $reihe = $this->reihe(); + $konten = $reihe['konten']; + $letzte = $konten[count($konten) - 1]; + + $this->assertEquals( + Vermoegensentwicklung::SERIEN, + count($konten), + 'Nicht mehr Balkenstücke als Farbtöne' + ); + $this->assertEquals(0, $letzte['id'], 'Das letzte Stück steht für kein einzelnes Konto'); + $this->assert(str_starts_with($letzte['name'], 'Übrige Konten ('), 'Es heißt „Übrige Konten“'); + + // Die größten behalten ihre eigene Farbe – das Sparbuch ist dabei. + $this->assertNotEmpty($this->konto($reihe, $this->sparkonto), 'Ein großes Konto bleibt eigenständig'); + + // Zusammengefasst wird, nicht weggelassen: Die Summe bleibt dieselbe. + $gestapelt = 0.0; + foreach ($konten as $konto) { + $gestapelt += $konto['werte'][11]; + } + $this->assertEquals( + $reihe['summen'][11], + round($gestapelt, 2), + 'Die Balkenstücke ergeben zusammen die Summe' + ); + } + + /** + * „Übrige Konten“ ist keine Sackgasse: Jede Ebene zeigt die nächstkleineren + * Konten einzeln, bis der Rest in die Palette passt. + */ + private function ebenenBlaetternDurchDieUebrigenKonten(): void + { + $this->assertEquals(1, Vermoegensentwicklung::ebenen(12), 'Was in die Palette passt, hat eine Ebene'); + $this->assertEquals(2, Vermoegensentwicklung::ebenen(13), 'Ein Konto zu viel macht die zweite Ebene auf'); + $this->assertEquals(2, Vermoegensentwicklung::ebenen(23), 'Elf eigene und zwölf übrige: noch zwei Ebenen'); + $this->assertEquals(3, Vermoegensentwicklung::ebenen(24), 'Eines mehr, und es braucht eine dritte'); + + // Aus dem vorigen Test stehen mehr Konten da, als die Palette Töne hat. + $erste = $this->reihe(true, 0); + $zweite = $this->reihe(true, 1); + + $this->assertGreaterThan(1, $erste['ebenen'], 'Der Testbestand füllt mehr als eine Ebene'); + $this->assertEquals(0, $erste['ebene'], 'Ohne Angabe steht man auf der ersten Ebene'); + $this->assertEquals(1, $zweite['ebene'], 'Die zweite Ebene ist erreichbar'); + + $ids = static fn(array $reihe): array => array_map( + static fn(array $konto): int => $konto['id'], + $reihe['konten'] + ); + + $this->assert(in_array(0, $ids($erste), true), 'Die erste Ebene sammelt den Rest ein'); + $this->assertEquals( + [], + array_intersect(array_diff($ids($erste), [0]), $ids($zweite)), + 'Kein Konto steht auf zwei Ebenen' + ); + + // Die letzte Ebene braucht kein Sammelstück mehr – sonst käme man nie an. + $letzte = $this->reihe(true, $erste['ebenen'] - 1); + $this->assert(!in_array(0, $ids($letzte), true), 'Auf der letzten Ebene stehen nur noch echte Konten'); + + // Zusammen ergeben alle Ebenen wieder den ganzen Bestand. + $gesehen = []; + for ($ebene = 0; $ebene < $erste['ebenen']; $ebene++) { + $gesehen = array_merge($gesehen, array_diff($ids($this->reihe(true, $ebene)), [0])); + } + $this->assertEquals( + count($gesehen), + count(array_unique($gesehen)), + 'Über alle Ebenen hinweg taucht kein Konto doppelt auf' + ); + $this->assertGreaterThan( + Vermoegensentwicklung::SERIEN, + count($gesehen), + 'Und in Summe sind es mehr Konten, als eine Ebene fasst' + ); + + // Eine Ebene jenseits der letzten gibt es nicht: Sie fällt darauf zurück. + $zuTief = $this->reihe(true, 99); + $this->assertEquals($erste['ebenen'] - 1, $zuTief['ebene'], 'Zu tief geraten landet man auf der letzten'); + $this->assertEquals($ids($letzte), $ids($zuTief), 'Mit denselben Konten'); + } + + private function skalaRundetAufLesbareSchritte(): void + { + $skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]); + $this->assertEquals(0.0, $skala['min'], 'Die Null gehört immer dazu'); + $this->assertGreaterThan(4869.0, $skala['max'], 'Der größte Wert passt hinein'); + $this->assertEquals(0.0, fmod($skala['max'], $skala['schritt']), 'Das Maximum liegt auf einem Schritt'); + + $mitMinus = Vermoegensentwicklung::skala([-820.0, 2500.0]); + $this->assert($mitMinus['min'] <= -820.0, 'Auch der negative Ausschlag passt hinein'); + $this->assert($mitMinus['max'] >= 2500.0, 'Und der positive'); + + $leer = Vermoegensentwicklung::skala([]); + $this->assertGreaterThan(0.0, $leer['schritt'], 'Ohne Werte bleibt die Skala brauchbar'); + } +} diff --git a/tests/e2e/specs/smoke.spec.js b/tests/e2e/specs/smoke.spec.js index aeddaf3..fa187c6 100644 --- a/tests/e2e/specs/smoke.spec.js +++ b/tests/e2e/specs/smoke.spec.js @@ -54,6 +54,72 @@ test.describe('Grundgerüst', () => { await expect(page.locator('.seitenleiste')).toContainText('Kontenübersicht') }) + test('Vermögensentwicklung zeigt zwölf gestapelte Monatsbalken', async ({ page }) => { + await anmelden(page) + + const kachel = page.locator('.kachel', { hasText: 'Vermögensentwicklung' }) + await expect(kachel.locator('svg.diagramm')).toBeVisible() + + // Zwölf Monate, jeder mit eigener Beschriftung unter dem Balken. + await expect(kachel.locator('.diagramm-beschriftung:not(.schwach)')).toHaveCount(12) + await expect(kachel.locator('.diagramm rect').first()).toBeVisible() + + // Jedes Balkenstück gehört zu einem Eintrag der Legende, keiner doppelt. + const farben = await kachel.locator('.legendenfarbe:not(.leer)').evaluateAll( + knoten => knoten.map(k => getComputedStyle(k).backgroundColor) + ) + expect(farben.length).toBeGreaterThan(0) + expect(new Set(farben).size).toBe(farben.length) + }) + + test('„Übrige Konten“ führt eine Ebene tiefer und wieder zurück', async ({ page }) => { + await anmelden(page) + + const kachel = page.locator('.kachel', { hasText: 'Vermögensentwicklung' }) + const sammel = kachel.locator('.legendenname.tiefer') + + // Der Testbestand hat mehr Konten als Farbtöne; sonst gäbe es nichts + // aufzuklappen und der Test hätte hier nichts zu prüfen. + await expect(sammel).toHaveText(/Übrige Konten \(\d+\)/) + + const oben = await kachel.locator('.legendenname').allTextContents() + await sammel.click() + await expect(page).toHaveURL(/ebene=1/) + await expect(kachel.locator('.diagrammpfad')).toContainText('Ebene 2 von') + + // Eine Ebene tiefer stehen andere Konten in der Legende. + const unten = await kachel.locator('.legendenname').allTextContents() + expect(unten.filter(name => oben.includes(name) && !name.startsWith('Übrige'))).toHaveLength(0) + + await kachel.locator('.diagrammpfad a').first().click() + await expect(page).toHaveURL(/ebene=0/) + expect(await kachel.locator('.legendenname').allTextContents()).toEqual(oben) + }) + + test('Schalter „nur aktive“ behält seinen Zustand', async ({ page }) => { + await anmelden(page) + + const schalter = page.locator('.kachel', { hasText: 'Vermögensentwicklung' }) + .locator('.schalter') + await expect(schalter).toContainText('nur aktive') + + // Ein früherer Lauf kann den Schalter umgelegt haben. + if (await schalter.getAttribute('aria-checked') === 'false') { + await schalter.click() + } + await expect(schalter).toHaveAttribute('aria-checked', 'true') + + await schalter.click() + await expect(schalter).toHaveAttribute('aria-checked', 'false') + + // Gespeichert, nicht nur angezeigt: Der Zustand überlebt den Neuaufruf. + await page.goto('/') + await expect(schalter).toHaveAttribute('aria-checked', 'false') + + await schalter.click() + await expect(schalter).toHaveAttribute('aria-checked', 'true') + }) + test('Einstellungsdialog zeigt alle Reiter', async ({ page }) => { await anmelden(page) await page.click('a[href="/einstellungen"]')