From 896a1e36ae0f15d8ea9f75bf48f91b44d5f726d0 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 13:13:56 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Cockpit:=20Verm=C3=B6gensentwicklung=20als?= =?UTF-8?q?=20gestapeltes=20Balkendiagramm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Kachel war ein Platzhalter und zeigt jetzt die Kontostände der letzten zwölf Monate je Monatsende – ein Balken pro Monat, ein Farbton pro Konto. Gezählt wird, was auch in der Kontenübersicht in die Summe zählt: Konten mit Σ-Kennzeichen. Der Schalter „nur aktive“ oben rechts blendet stillgelegte Konten aus und merkt sich seinen Zustand über die Einstellungen. Mehr Konten als Farbtöne fasst „Übrige Konten (n)“ zusammen – 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. Co-Authored-By: Claude Opus 5 --- Changes.md | 19 ++ PLAN.md | 5 +- README.md | 6 +- docs/cockpit.md | 72 +++++++ public/index.php | 1 + public/static/app.css | 164 +++++++++++++++ src/Controllers/CockpitController.php | 30 ++- src/Models/Buchung.php | 45 ++++ src/Services/Vermoegensentwicklung.php | 213 +++++++++++++++++++ src/Views/cockpit/index.php | 38 +++- src/Views/cockpit/vermoegensdiagramm.php | 143 +++++++++++++ tests/13_VermoegensentwicklungTest.php | 248 +++++++++++++++++++++++ tests/e2e/specs/smoke.spec.js | 42 ++++ 13 files changed, 1015 insertions(+), 11 deletions(-) create mode 100644 docs/cockpit.md create mode 100644 src/Services/Vermoegensentwicklung.php create mode 100644 src/Views/cockpit/vermoegensdiagramm.php create mode 100644 tests/13_VermoegensentwicklungTest.php diff --git a/Changes.md b/Changes.md index adcdde7..7b0a24c 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,24 @@ # Ä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`). +- Gezählt wird, was auch in der Kontenübersicht in die Summe zählt: Konten mit + Σ-Kennzeichen. Negative Stände stapeln unter der Nulllinie. +- Schalter *nur aktive* oben rechts an der Kachel, im Standard eingeschaltet: Er blendet + stillgelegte Konten aus und merkt sich seinen Zustand über die Einstellungen + (`cockpit_vermoegen_nur_aktive`) – auch über das Abmelden hinaus. +- `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. + ## 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..5f31de0 --- /dev/null +++ b/docs/cockpit.md @@ -0,0 +1,72 @@ +# 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 + +`Vermoegensentwicklung::reihe()` zählt dieselben Konten wie die Summe der Kontenübersicht: +die mit gesetztem Σ-Kennzeichen (`in_summe`). 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. + +### 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. + +## Tests + +* `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände, + Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette, + Skala +* `tests/e2e/specs/smoke.spec.js` – die Kachel zeichnet zwölf Balken, keine Farbe der + Legende kommt doppelt vor, und der Schalter behält seinen Zustand über den Neuaufruf 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..100ff7d 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -274,8 +274,28 @@ form.inline { display: inline; margin: 0; } .kachel.einzeln { max-width: 640px; } +/* Ein Diagramm mit zwölf Balken braucht mehr als eine Spalte – aber nicht die + ganze Zeile: über zwei Spalten bleibt es lesbar und die Kachel handlich. Unter + der Umbruchbreite muss der Ausgriff weg, sonst erzwänge er eine zweite Spalte, + die dort nicht mehr hineinpasst (siehe Media Query am Dateiende). */ +.kachel.breit { grid-column: span 2; } + .kachel header { margin-bottom: .6rem; } +.kachel header.mitschalter { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.kachelhinweis { + margin: .25rem 0 0; + color: var(--text-gedaempft); + font-size: .85rem; +} + .kachel h2 { margin: 0; font-size: 1rem; @@ -321,6 +341,146 @@ 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; } + +.diagramm { + width: 100%; + height: auto; + 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: 11px; +} + +.diagramm-achse { font-family: var(--schrift-zahl); } + +.diagramm-beschriftung.schwach { opacity: .7; } + +.diagrammlegende { + list-style: none; + margin: .9rem 0 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: .1rem 1.2rem; + font-size: .88rem; +} + +.diagrammlegende li { + display: flex; + align-items: center; + gap: .5rem; + padding: .15rem 0; +} + +.legendenfarbe { + width: .8rem; + height: .8rem; + border-radius: 2px; + background: var(--farbe); + flex: none; +} + +.legendenname { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.legendenwert { + margin-left: auto; + font-family: var(--schrift-zahl); + font-variant-numeric: tabular-nums; +} + +/* ── 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 { @@ -836,6 +996,10 @@ body.schlicht { @media (max-width: 820px) { .rahmen { grid-template-columns: 1fr; } + /* Einspaltiges Raster: Ein Ausgriff über zwei Spalten erzwänge hier eine + zweite Spalte und damit den waagerechten Rollbalken. */ + .kachel.breit { grid-column: auto; } + .seitenleiste { position: static; height: auto; diff --git a/src/Controllers/CockpitController.php b/src/Controllers/CockpitController.php index 5feb25b..e64e8f4 100644 --- a/src/Controllers/CockpitController.php +++ b/src/Controllers/CockpitController.php @@ -4,19 +4,45 @@ 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); + $this->render('cockpit/index.php', [ 'titel' => 'Cockpit', 'uebersicht' => Konto::uebersicht(), + 'vermoegen' => Vermoegensentwicklung::reihe($nurAktive), + '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..977592c --- /dev/null +++ b/src/Services/Vermoegensentwicklung.php @@ -0,0 +1,213 @@ +<?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*. + * + * Gezählt wird, was auch in der Kontenübersicht in die Summe zählt: Konten mit + * gesetztem Kennzeichen `in_summe`. Der Schalter „nur aktive“ an der Kachel + * entscheidet, ob stillgelegte Konten dazugehören: ausgeschaltet zeigt er auch + * das Vermögen, das damals auf inzwischen aufgelösten Konten lag. + */ +class Vermoegensentwicklung +{ + public const MONATE = 12; + + /** So viele Farbtöne hat die Palette in `app.css`. */ + public const SERIEN = 12; + + /** + * @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 + * } + */ + public static function reihe( + bool $nurAktive = true, + int $anzahl = self::MONATE, + ?string $heute = null + ): 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) { + if ((int)$konto['in_summe'] !== 1) { + continue; + } + + $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]; + } + } + + if (count($konten) > self::SERIEN) { + $konten = self::zusammenfassen($konten, count($monate)); + } + + $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 !== [], + ]; + } + + /** + * 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. + * + * @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): array + { + $groesse = []; + foreach ($konten as $nummer => $konto) { + $groesse[$nummer] = max(array_map('abs', $konto['werte'])); + } + arsort($groesse); + + // Die größten behalten, aber in ihrer ursprünglichen Reihenfolge – der + // Stapel soll nicht bei jedem Aufruf anders sortiert sein. + $eigene = array_slice(array_keys($groesse), 0, self::SERIEN - 1); + 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; + } + + foreach ($konto['werte'] as $spalte => $wert) { + $rest[$spalte] += $wert; + } + $restKonten++; + } + + $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..453e263 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'; ?> @@ -12,10 +15,35 @@ include __DIR__ . '/../layout/kopf.php'; </div> <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> + <section class="kachel breit"> + <header class="mitschalter"> + <div> + <h2>Vermögensentwicklung</h2> + <p class="kachelhinweis">Kontostände der letzten zwölf Monate, je Monatsende + gestapelt – ein Farbton pro Konto.</p> + </div> + + <?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> + </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 +94,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..29c05bc --- /dev/null +++ b/src/Views/cockpit/vermoegensdiagramm.php @@ -0,0 +1,143 @@ +<?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 + * } $reihe + */ + +$monate = $reihe['monate']; +$konten = $reihe['konten']; +$anzahl = count($monate); + +// 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); + +$breite = 760; +$hoehe = 250; +$links = 82; +$rechts = 10; +$oben = 12; +$unten = 34; + +$plotB = $breite - $links - $rechts; +$plotH = $hoehe - $oben - $unten; +$spalte = $plotB / max(1, $anzahl); +$balken = min(40.0, $spalte * 0.62); + +$y = static function (float $wert) use ($skala, $oben, $plotH): float { + return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2); +}; + +$mitte = static function (int $nummer) use ($links, $spalte): float { + return round($links + ($nummer + 0.5) * $spalte, 2); +}; + +$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']; +?> + +<svg class="diagramm" viewBox="0 0 <?= $breite ?> <?= $hoehe ?>" role="img" + preserveAspectRatio="xMidYMid meet" + 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)) ?> + + + + + + + + + + + diff --git a/tests/13_VermoegensentwicklungTest.php b/tests/13_VermoegensentwicklungTest.php new file mode 100644 index 0000000..24a0825 --- /dev/null +++ b/tests/13_VermoegensentwicklungTest.php @@ -0,0 +1,248 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->zwoelfMonateBisHeute(); + $this->staendeWachsenMonatFuerMonat(); + $this->umbuchungVerschiebtZwischenKonten(); + $this->ohneSummenkennzeichenKeinBalken(); + $this->schalterFiltertStillgelegteKonten(); + $this->mehrKontenAlsFarbenWerdenZusammengefasst(); + $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): array + { + return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE); + } + + /** @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->assertEquals(1600.0, $reihe['summen'][11], 'Summe ist Giro minus Kreditkarte'); + $this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen'); + } + + 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' + ); + } + + private function ohneSummenkennzeichenKeinBalken(): void + { + $reihe = $this->reihe(); + + $this->assertNull( + $this->konto($reihe, $this->auslagen), + 'Ein Konto ohne Σ-Kennzeichen zeichnet keinen 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' + ); + } + + 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..6fbd491 100644 --- a/tests/e2e/specs/smoke.spec.js +++ b/tests/e2e/specs/smoke.spec.js @@ -54,6 +54,48 @@ 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('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"]') From 7932830defee9196954316ef41c8d0551601e43c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 13:28:50 +0200 Subject: [PATCH 2/5] =?UTF-8?q?Cockpit:=20Legende=20ohne=20Betr=C3=A4ge,?= =?UTF-8?q?=20Kachel=20ein=20Viertel=20schmaler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Beträge stehen am Balken im Tooltip und in der Kachel Kontostände – in der Legende kosteten sie nur Breite. Ohne sie kommen die Namen mit schmalen Spalten aus, und die Kachel greift im engeren Cockpit-Raster über zwei Spalten statt über zwei breite: 602 statt 808 Pixel. Dazu zwei Anpassungen an die schmalere Kachel: Der Kopftext gibt nach, damit der Schalter oben rechts stehen bleibt, und die Schrift im SVG wächst, weil sie mit dem viewBox schrumpft. Co-Authored-By: Claude Opus 5 --- docs/cockpit.md | 6 ++++++ public/static/app.css | 23 +++++++++++++++-------- src/Views/cockpit/index.php | 2 +- src/Views/cockpit/vermoegensdiagramm.php | 11 +++++------ 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/docs/cockpit.md b/docs/cockpit.md index 5f31de0..bb9fbb0 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -63,6 +63,12 @@ Die Achse rundet über `Vermoegensentwicklung::skala()` auf Schritte von 1, 2, 2 mal einer Zehnerpotenz und schließt die Null immer ein. Jedes Balkenstück trägt ein `` mit Konto, Monat und Betrag – das ist der Tooltip, ohne eine Zeile JavaScript. +Die Legende führt nur Farbe und Kontoname. Die Beträge stehen am Balken im Tooltip und in +der Kachel *Kontostände*; in der Legende kosteten sie nur Breite. Die Kachel greift über +zwei Spalten des Cockpit-Rasters (`.kachelraster.dicht`) – schmal genug, um die übrigen +Kacheln danebenstehen zu lassen, breit genug für zwölf Balken. Unterhalb von 820 px fällt +der Ausgriff weg, sonst erzwänge er im einspaltigen Raster einen waagerechten Rollbalken. + ## Tests * `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände, diff --git a/public/static/app.css b/public/static/app.css index 100ff7d..d5401d4 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -264,6 +264,10 @@ form.inline { display: inline; margin: 0; } gap: 1rem; } +/* Engere Spalten für das Cockpit: Die Diagrammkachel greift über zwei davon und + bleibt damit schmal, ohne dass eine Lücke zur Nachbarkachel entsteht. */ +.kachelraster.dicht { grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); } + .kachel { background: var(--flaeche); border: 1px solid var(--rand); @@ -290,6 +294,13 @@ form.inline { display: inline; margin: 0; } flex-wrap: wrap; } +/* Der Text gibt nach, damit der Schalter oben rechts stehen bleibt und nicht + unter die Überschrift rutscht; erst ganz schmal bricht die Zeile um. */ +.kachel header.mitschalter > div { + flex: 1 1 11rem; + min-width: 0; +} + .kachelhinweis { margin: .25rem 0 0; color: var(--text-gedaempft); @@ -379,11 +390,13 @@ form.inline { display: inline; margin: 0; } stroke-width: 1.5; } +/* Die Schrift skaliert mit dem viewBox: In der schmalen Kachel schrumpft sie um + ein Viertel, deshalb hier größer als im Fließtext angesetzt. */ .diagramm-achse, .diagramm-beschriftung { fill: var(--text-gedaempft); font-family: var(--schrift); - font-size: 11px; + font-size: 14px; } .diagramm-achse { font-family: var(--schrift-zahl); } @@ -395,7 +408,7 @@ form.inline { display: inline; margin: 0; } margin: .9rem 0 0; padding: 0; display: grid; - grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: .1rem 1.2rem; font-size: .88rem; } @@ -421,12 +434,6 @@ form.inline { display: inline; margin: 0; } white-space: nowrap; } -.legendenwert { - margin-left: auto; - font-family: var(--schrift-zahl); - font-variant-numeric: tabular-nums; -} - /* ── Ein/Aus-Schalter ──────────────────────────────────────────────────────── Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein diff --git a/src/Views/cockpit/index.php b/src/Views/cockpit/index.php index 453e263..03e0561 100644 --- a/src/Views/cockpit/index.php +++ b/src/Views/cockpit/index.php @@ -14,7 +14,7 @@ include __DIR__ . '/../layout/kopf.php'; <span class="tab aktiv">Cockpit</span> </div> -<div class="kachelraster"> +<div class="kachelraster dicht"> <section class="kachel breit"> <header class="mitschalter"> <div> diff --git a/src/Views/cockpit/vermoegensdiagramm.php b/src/Views/cockpit/vermoegensdiagramm.php index 29c05bc..e77b49d 100644 --- a/src/Views/cockpit/vermoegensdiagramm.php +++ b/src/Views/cockpit/vermoegensdiagramm.php @@ -49,7 +49,7 @@ $hoehe = 250; $links = 82; $rechts = 10; $oben = 12; -$unten = 34; +$unten = 40; $plotB = $breite - $links - $rechts; $plotH = $hoehe - $oben - $unten; @@ -121,10 +121,10 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz'] </rect> <?php endforeach; ?> - <text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 14 ?>" + <text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 22 ?>" text-anchor="middle"><?= View::h($monat['kuerzel']) ?></text> <?php if ($monat['monat'] === 1 || $nummer === 0): ?> - <text class="diagramm-beschriftung schwach" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 3 ?>" + <text class="diagramm-beschriftung schwach" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 6 ?>" text-anchor="middle"><?= View::h($monat['jahrKurz']) ?></text> <?php endif; ?> <?php endforeach; ?> @@ -132,12 +132,11 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz'] <ul class="diagrammlegende"> <?php foreach ($konten as $index => $konto): ?> + <?php /* Nur Farbe und Name: Die Beträge stehen am Balken im Tooltip und in + der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. */ ?> <li> <span class="legendenfarbe <?= $serie($index) ?>" aria-hidden="true"></span> <span class="legendenname"><?= View::h($konto['name']) ?></span> - <span class="legendenwert <?= View::betragKlasse($konto['werte'][$anzahl - 1]) ?>"> - <?= View::betrag($konto['werte'][$anzahl - 1]) ?> - </span> </li> <?php endforeach; ?> </ul> From 0285b760fc5aa3219a47b8ae257bf93812bdc912 Mon Sep 17 00:00:00 2001 From: fds2610-bot <claude@sonnengetrock.net> Date: Tue, 11 Aug 2026 13:45:55 +0200 Subject: [PATCH 3/5] Cockpit: Schalter filtert wirklich, Balken 18 statt 26 Punkte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Schalter „nur aktive“ änderte kaum etwas: Neben ihm filterte noch das Σ-Kennzeichen, und stillgelegte Konten tragen es meist nicht – von 17 inaktiven Konten kam beim Ausschalten genau eines dazu. Das Kennzeichen filtert hier nicht mehr mit, der Schalter ist das einzige Kriterium. Balken jetzt 18 Bildpunkte breit mit 3 Punkten Abstand. Damit das auf dem Schirm ankommt, trägt das SVG seine Maße als Attribute und wird nicht mehr auf die Kachel gestreckt. Die Legende steht neben der Zeichnung, die sonst die halbe Kachel leer ließe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- Changes.md | 10 ++++--- docs/cockpit.md | 24 ++++++++++++----- public/static/app.css | 32 +++++++++++++++++----- src/Services/Vermoegensentwicklung.php | 12 +++------ src/Views/cockpit/vermoegensdiagramm.php | 34 +++++++++++++++--------- tests/13_VermoegensentwicklungTest.php | 17 ++++++++---- 6 files changed, 85 insertions(+), 44 deletions(-) diff --git a/Changes.md b/Changes.md index 7b0a24c..44d5929 100644 --- a/Changes.md +++ b/Changes.md @@ -5,11 +5,13 @@ - 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`). -- Gezählt wird, was auch in der Kontenübersicht in die Summe zählt: Konten mit - Σ-Kennzeichen. Negative Stände stapeln unter der Nulllinie. +- Negative Stände stapeln unter der Nulllinie. - Schalter *nur aktive* oben rechts an der Kachel, im Standard eingeschaltet: Er blendet - stillgelegte Konten aus und merkt sich seinen Zustand über die Einstellungen - (`cockpit_vermoegen_nur_aktive`) – auch über das Abmelden hinaus. + 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 diff --git a/docs/cockpit.md b/docs/cockpit.md index bb9fbb0..3257d89 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -10,9 +10,13 @@ Monate – ein Balken je Monat, ein Farbton je Konto. ### Woher die Zahlen kommen -`Vermoegensentwicklung::reihe()` zählt dieselben Konten wie die Summe der Kontenübersicht: -die mit gesetztem Σ-Kennzeichen (`in_summe`). Der Stand eines Monats ist der Stand des -Vormonats plus dessen Bewegungen: +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. @@ -63,11 +67,17 @@ Die Achse rundet über `Vermoegensentwicklung::skala()` auf Schritte von 1, 2, 2 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. -Die Legende führt nur Farbe und Kontoname. Die Beträge stehen am Balken im Tooltip und in +Die Maße stehen in echten Bildpunkten: Ein Balken ist 18 Punkte breit, zwischen zwei +Monaten bleiben 3. Damit das auch auf dem Schirm so ankommt, trägt das `<svg>` Breite und +Höhe als Attribute und wird **nicht** auf die Kachel gestreckt; `max-width: 100%` lässt es +erst mitschrumpfen, wenn die Kachel schmaler wird als die Zeichnung. + +Die Legende führt nur Farbe und Kontoname und steht neben der Zeichnung – die schmale +Zeichnung ließe sonst die halbe Kachel leer. Die Beträge stehen am Balken im Tooltip und in der Kachel *Kontostände*; in der Legende kosteten sie nur Breite. Die Kachel greift über -zwei Spalten des Cockpit-Rasters (`.kachelraster.dicht`) – schmal genug, um die übrigen -Kacheln danebenstehen zu lassen, breit genug für zwölf Balken. Unterhalb von 820 px fällt -der Ausgriff weg, sonst erzwänge er im einspaltigen Raster einen waagerechten Rollbalken. +zwei Spalten des Cockpit-Rasters (`.kachelraster.dicht`). Unterhalb von 820 px fällt der +Ausgriff weg, sonst erzwänge er im einspaltigen Raster einen waagerechten Rollbalken; dort +rutscht auch die Legende unter die Zeichnung. ## Tests diff --git a/public/static/app.css b/public/static/app.css index d5401d4..f7dea5e 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -371,11 +371,22 @@ form.inline { display: inline; margin: 0; } .serie-11 { --farbe: #e07b91; } .serie-12 { --farbe: #7b8a99; } +/* Das SVG bringt seine Maße als Attribute mit und wird nicht auf die Kachel + gestreckt: Nur so sind die 18 Punkte Balkenbreite auch 18 Bildpunkte. Erst + wenn die Kachel schmaler wird als die Zeichnung, skaliert sie mit. */ +.diagrammbereich { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: .5rem 1.5rem; +} + .diagramm { - width: 100%; + max-width: 100%; height: auto; display: block; overflow: visible; + flex: 0 1 auto; } .diagramm rect { fill: var(--farbe); } @@ -390,27 +401,34 @@ form.inline { display: inline; margin: 0; } stroke-width: 1.5; } -/* Die Schrift skaliert mit dem viewBox: In der schmalen Kachel schrumpft sie um - ein Viertel, deshalb hier größer als im Fließtext angesetzt. */ .diagramm-achse, .diagramm-beschriftung { fill: var(--text-gedaempft); font-family: var(--schrift); - font-size: 14px; + font-size: 10px; } -.diagramm-achse { font-family: var(--schrift-zahl); } +/* 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; } +/* Neben der Zeichnung statt darunter: Die schmale Zeichnung ließe sonst die + halbe Kachel leer. */ .diagrammlegende { list-style: none; - margin: .9rem 0 0; + margin: 0; padding: 0; + flex: 1 1 11rem; display: grid; - grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .1rem 1.2rem; font-size: .88rem; + align-content: start; } .diagrammlegende li { diff --git a/src/Services/Vermoegensentwicklung.php b/src/Services/Vermoegensentwicklung.php index 977592c..cf790e5 100644 --- a/src/Services/Vermoegensentwicklung.php +++ b/src/Services/Vermoegensentwicklung.php @@ -11,10 +11,10 @@ use Finanzmanager\Models\Konto; * Die Kontostände der letzten zwölf Monate, je Konto und Monatsende – Grundlage * der Cockpit-Kachel *Vermögensentwicklung*. * - * Gezählt wird, was auch in der Kontenübersicht in die Summe zählt: Konten mit - * gesetztem Kennzeichen `in_summe`. Der Schalter „nur aktive“ an der Kachel - * entscheidet, ob stillgelegte Konten dazugehören: ausgeschaltet zeigt er auch - * das Vermögen, das damals auf inzwischen aufgelösten Konten lag. + * 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 { @@ -54,10 +54,6 @@ class Vermoegensentwicklung $summen = array_fill(0, count($monate), 0.0); foreach (Konto::alle($nurAktive) as $konto) { - if ((int)$konto['in_summe'] !== 1) { - continue; - } - $id = (int)$konto['id']; $stand = (float)$konto['anfangssaldo'] + ($anfang[$id] ?? 0.0); $werte = []; diff --git a/src/Views/cockpit/vermoegensdiagramm.php b/src/Views/cockpit/vermoegensdiagramm.php index e77b49d..e343d8c 100644 --- a/src/Views/cockpit/vermoegensdiagramm.php +++ b/src/Views/cockpit/vermoegensdiagramm.php @@ -44,17 +44,22 @@ foreach ($monate as $nummer => $monat) { $skala = Vermoegensentwicklung::skala($grenzwerte); -$breite = 760; -$hoehe = 250; -$links = 82; -$rechts = 10; -$oben = 12; -$unten = 40; +// Maße in echten Bildpunkten: Das SVG trägt Breite und Höhe als Attribute und +// wird deshalb nicht auf die Kachel gestreckt. Ein Balken ist 18 Punkte breit, +// zwischen zwei Monaten bleiben 3 – erst unterhalb der eigenen Breite skaliert +// die Zeichnung mit (max-width in app.css). +$balken = 18; +$luecke = 3; +$links = 52; +$rechts = 6; +$oben = 10; +$unten = 32; -$plotB = $breite - $links - $rechts; -$plotH = $hoehe - $oben - $unten; -$spalte = $plotB / max(1, $anzahl); -$balken = min(40.0, $spalte * 0.62); +$spalte = $balken + $luecke; +$plotB = $spalte * $anzahl; +$plotH = 150; +$breite = $links + $plotB + $rechts; +$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); @@ -76,7 +81,9 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz'] . ' bis ' . $monate[$anzahl - 1]['kuerzel'] . ' ' . $monate[$anzahl - 1]['jahrKurz']; ?> -<svg class="diagramm" viewBox="0 0 <?= $breite ?> <?= $hoehe ?>" role="img" +<div class="diagrammbereich"> +<svg class="diagramm" width="<?= $breite ?>" height="<?= $hoehe ?>" + viewBox="0 0 <?= $breite ?> <?= $hoehe ?>" role="img" preserveAspectRatio="xMidYMid meet" aria-label="Kontostände je Monatsende, <?= View::h($zeitraum) ?>"> <title>Vermögensentwicklung <?= View::h($zeitraum) ?> @@ -121,10 +128,10 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz'] - - @@ -140,3 +147,4 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz'] + diff --git a/tests/13_VermoegensentwicklungTest.php b/tests/13_VermoegensentwicklungTest.php index 24a0825..4b0a130 100644 --- a/tests/13_VermoegensentwicklungTest.php +++ b/tests/13_VermoegensentwicklungTest.php @@ -28,7 +28,7 @@ class VermoegensentwicklungTest extends TestCase $this->zwoelfMonateBisHeute(); $this->staendeWachsenMonatFuerMonat(); $this->umbuchungVerschiebtZwischenKonten(); - $this->ohneSummenkennzeichenKeinBalken(); + $this->summenkennzeichenFiltertNicht(); $this->schalterFiltertStillgelegteKonten(); $this->mehrKontenAlsFarbenWerdenZusammengefasst(); $this->skalaRundetAufLesbareSchritte(); @@ -119,8 +119,10 @@ class VermoegensentwicklungTest extends TestCase // 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->assertEquals(1600.0, $reihe['summen'][11], 'Summe ist Giro minus Kreditkarte'); $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 @@ -144,13 +146,18 @@ class VermoegensentwicklungTest extends TestCase ); } - private function ohneSummenkennzeichenKeinBalken(): void + /** + * 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->assertNull( + $this->assertNotEmpty( $this->konto($reihe, $this->auslagen), - 'Ein Konto ohne Σ-Kennzeichen zeichnet keinen Balken' + 'Ein aktives Konto ohne Σ-Kennzeichen zeichnet trotzdem einen Balken' ); // Ein Konto, das im ganzen Zeitraum auf null steht, bläht nur die Legende auf. From 1faf10aeda7b83e0e43a6b0e3a1eda02a9ae0941 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 11 Aug 2026 14:04:53 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Cockpit:=20Verm=C3=B6genskachel=20so=20gro?= =?UTF-8?q?=C3=9F=20wie=20ihre=20Nachbarn,=20Legende=20darunter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- Changes.md | 5 ++++ docs/cockpit.md | 15 +++++----- public/static/app.css | 57 ++++++++++++++----------------------- src/Views/cockpit/index.php | 13 ++++----- 4 files changed, 41 insertions(+), 49 deletions(-) diff --git a/Changes.md b/Changes.md index 44d5929..45fc381 100644 --- a/Changes.md +++ b/Changes.md @@ -20,6 +20,11 @@ - 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. +- 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) diff --git a/docs/cockpit.md b/docs/cockpit.md index 3257d89..902bc83 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -70,14 +70,15 @@ mal einer Zehnerpotenz und schließt die Null immer ein. Jedes Balkenstück trä Die Maße stehen in echten Bildpunkten: Ein Balken ist 18 Punkte breit, zwischen zwei Monaten bleiben 3. Damit das auch auf dem Schirm so ankommt, trägt das `<svg>` Breite und Höhe als Attribute und wird **nicht** auf die Kachel gestreckt; `max-width: 100%` lässt es -erst mitschrumpfen, wenn die Kachel schmaler wird als die Zeichnung. +erst mitschrumpfen, wenn die Kachel schmaler wird als die Zeichnung – in der dreispaltigen +Kachel also auf gut 90 Prozent. -Die Legende führt nur Farbe und Kontoname und steht neben der Zeichnung – die schmale -Zeichnung ließe sonst die halbe Kachel leer. Die Beträge stehen am Balken im Tooltip und in -der Kachel *Kontostände*; in der Legende kosteten sie nur Breite. Die Kachel greift über -zwei Spalten des Cockpit-Rasters (`.kachelraster.dicht`). Unterhalb von 820 px fällt der -Ausgriff weg, sonst erzwänge er im einspaltigen Raster einen waagerechten Rollbalken; dort -rutscht auch die Legende unter die Zeichnung. +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 diff --git a/public/static/app.css b/public/static/app.css index f7dea5e..a852fdd 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -264,10 +264,6 @@ form.inline { display: inline; margin: 0; } gap: 1rem; } -/* Engere Spalten für das Cockpit: Die Diagrammkachel greift über zwei davon und - bleibt damit schmal, ohne dass eine Lücke zur Nachbarkachel entsteht. */ -.kachelraster.dicht { grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); } - .kachel { background: var(--flaeche); border: 1px solid var(--rand); @@ -278,28 +274,22 @@ form.inline { display: inline; margin: 0; } .kachel.einzeln { max-width: 640px; } -/* Ein Diagramm mit zwölf Balken braucht mehr als eine Spalte – aber nicht die - ganze Zeile: über zwei Spalten bleibt es lesbar und die Kachel handlich. Unter - der Umbruchbreite muss der Ausgriff weg, sonst erzwänge er eine zweite Spalte, - die dort nicht mehr hineinpasst (siehe Media Query am Dateiende). */ -.kachel.breit { grid-column: span 2; } - .kachel header { margin-bottom: .6rem; } .kachel header.mitschalter { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 1rem; + gap: .5rem 1rem; flex-wrap: wrap; } -/* Der Text gibt nach, damit der Schalter oben rechts stehen bleibt und nicht - unter die Überschrift rutscht; erst ganz schmal bricht die Zeile um. */ -.kachel header.mitschalter > div { - flex: 1 1 11rem; - min-width: 0; -} +/* 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; @@ -373,12 +363,13 @@ form.inline { display: inline; margin: 0; } /* Das SVG bringt seine Maße als Attribute mit und wird nicht auf die Kachel gestreckt: Nur so sind die 18 Punkte Balkenbreite auch 18 Bildpunkte. Erst - wenn die Kachel schmaler wird als die Zeichnung, skaliert sie mit. */ + wenn die Kachel schmaler wird als die Zeichnung, skaliert sie mit – die + Legende darunter nimmt dann die volle Kachelbreite. */ .diagrammbereich { display: flex; - flex-wrap: wrap; + flex-direction: column; align-items: flex-start; - gap: .5rem 1.5rem; + gap: .6rem; } .diagramm { @@ -417,30 +408,30 @@ form.inline { display: inline; margin: 0; } .diagramm-beschriftung.schwach { opacity: .7; } -/* Neben der Zeichnung statt darunter: Die schmale Zeichnung ließe sonst die - halbe Kachel leer. */ +/* 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; - flex: 1 1 11rem; + align-self: stretch; display: grid; - grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); - gap: .1rem 1.2rem; - font-size: .88rem; + 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: .5rem; - padding: .15rem 0; + gap: .4rem; + padding: .1rem 0; } .legendenfarbe { - width: .8rem; - height: .8rem; + width: .65rem; + height: .65rem; border-radius: 2px; background: var(--farbe); flex: none; @@ -1021,10 +1012,6 @@ body.schlicht { @media (max-width: 820px) { .rahmen { grid-template-columns: 1fr; } - /* Einspaltiges Raster: Ein Ausgriff über zwei Spalten erzwänge hier eine - zweite Spalte und damit den waagerechten Rollbalken. */ - .kachel.breit { grid-column: auto; } - .seitenleiste { position: static; height: auto; diff --git a/src/Views/cockpit/index.php b/src/Views/cockpit/index.php index 03e0561..d8ba280 100644 --- a/src/Views/cockpit/index.php +++ b/src/Views/cockpit/index.php @@ -14,14 +14,10 @@ include __DIR__ . '/../layout/kopf.php'; <span class="tab aktiv">Cockpit</span> </div> -<div class="kachelraster dicht"> - <section class="kachel breit"> +<div class="kachelraster"> + <section class="kachel"> <header class="mitschalter"> - <div> - <h2>Vermögensentwicklung</h2> - <p class="kachelhinweis">Kontostände der letzten zwölf Monate, je Monatsende - gestapelt – ein Farbton pro Konto.</p> - </div> + <h2>Vermögensentwicklung</h2> <?php /* Ein Knopf, kein Ankreuzfeld: Ohne Inline-Skript könnte ein Ankreuzfeld das Formular nicht selbst abschicken, und die @@ -35,6 +31,9 @@ include __DIR__ . '/../layout/kopf.php'; <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"> From 048bbf3894200685c22715047848d2db34c6b898 Mon Sep 17 00:00:00 2001 From: fds2610-bot <claude@sonnengetrock.net> Date: Tue, 11 Aug 2026 14:22:06 +0200 Subject: [PATCH 5/5] =?UTF-8?q?Cockpit:=20=C3=9Cbrige=20Konten=20aufklappb?= =?UTF-8?q?ar,=20Balken=20r=C3=BCcken=20auseinander?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- Changes.md | 8 ++ docs/cockpit.md | 38 +++++- public/static/app.css | 39 ++++-- src/Controllers/CockpitController.php | 12 +- src/Services/Vermoegensentwicklung.php | 67 ++++++++-- src/Views/cockpit/vermoegensdiagramm.php | 161 +++++++++++++---------- tests/13_VermoegensentwicklungTest.php | 62 ++++++++- tests/e2e/specs/smoke.spec.js | 24 ++++ 8 files changed, 313 insertions(+), 98 deletions(-) diff --git a/Changes.md b/Changes.md index 45fc381..9e07578 100644 --- a/Changes.md +++ b/Changes.md @@ -20,6 +20,14 @@ - 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 `<title>` 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); diff --git a/docs/cockpit.md b/docs/cockpit.md index 902bc83..20443f8 100644 --- a/docs/cockpit.md +++ b/docs/cockpit.md @@ -56,6 +56,23 @@ zusammengefasst – zwei Konten in derselben Farbe wären in der Legende nicht a 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` @@ -67,11 +84,16 @@ Die Achse rundet über `Vermoegensentwicklung::skala()` auf Schritte von 1, 2, 2 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. -Die Maße stehen in echten Bildpunkten: Ein Balken ist 18 Punkte breit, zwischen zwei -Monaten bleiben 3. Damit das auch auf dem Schirm so ankommt, trägt das `<svg>` Breite und -Höhe als Attribute und wird **nicht** auf die Kachel gestreckt; `max-width: 100%` lässt es -erst mitschrumpfen, wenn die Kachel schmaler wird als die Zeichnung – in der dreispaltigen -Kachel also auf gut 90 Prozent. +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. @@ -84,6 +106,8 @@ Tooltip und in der Kachel *Kontostände*; in der Legende kosteten sie nur Breite * `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände, Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette, - Skala + 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, und der Schalter behält seinen Zustand über den Neuaufruf + 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/static/app.css b/public/static/app.css index a852fdd..3599ee2 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -361,23 +361,30 @@ form.inline { display: inline; margin: 0; } .serie-11 { --farbe: #e07b91; } .serie-12 { --farbe: #7b8a99; } -/* Das SVG bringt seine Maße als Attribute mit und wird nicht auf die Kachel - gestreckt: Nur so sind die 18 Punkte Balkenbreite auch 18 Bildpunkte. Erst - wenn die Kachel schmaler wird als die Zeichnung, skaliert sie mit – die - Legende darunter nimmt dann die volle Kachelbreite. */ .diagrammbereich { display: flex; flex-direction: column; - align-items: flex-start; 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 { - max-width: 100%; - height: auto; + flex: 1 1 0; + min-width: 0; display: block; overflow: visible; - flex: 0 1 auto; } .diagramm rect { fill: var(--farbe); } @@ -443,6 +450,22 @@ form.inline { display: inline; margin: 0; } 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 diff --git a/src/Controllers/CockpitController.php b/src/Controllers/CockpitController.php index e64e8f4..c5956d0 100644 --- a/src/Controllers/CockpitController.php +++ b/src/Controllers/CockpitController.php @@ -25,10 +25,20 @@ class CockpitController extends BaseController $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), + 'vermoegen' => Vermoegensentwicklung::reihe( + $nurAktive, + Vermoegensentwicklung::MONATE, + null, + $ebene + ), 'nurAktive' => $nurAktive, ]); } diff --git a/src/Services/Vermoegensentwicklung.php b/src/Services/Vermoegensentwicklung.php index cf790e5..4a521de 100644 --- a/src/Services/Vermoegensentwicklung.php +++ b/src/Services/Vermoegensentwicklung.php @@ -24,17 +24,23 @@ class Vermoegensentwicklung 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 + * hatWerte: bool, + * ebene: int, + * ebenen: int * } */ public static function reihe( bool $nurAktive = true, int $anzahl = self::MONATE, - ?string $heute = null + ?string $heute = null, + int $ebene = 0 ): array { $monate = self::monate($anzahl, $heute); $erster = $monate[0]; @@ -75,8 +81,11 @@ class Vermoegensentwicklung } } + $ebenen = self::ebenen(count($konten)); + $ebene = max(0, min($ebene, $ebenen - 1)); + if (count($konten) > self::SERIEN) { - $konten = self::zusammenfassen($konten, count($monate)); + $konten = self::zusammenfassen($konten, count($monate), $ebene); } $hatWerte = false; @@ -90,18 +99,38 @@ class Vermoegensentwicklung '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): array + private static function zusammenfassen(array $konten, int $spalten, int $ebene = 0): array { $groesse = []; foreach ($konten as $nummer => $konto) { @@ -109,14 +138,20 @@ class Vermoegensentwicklung } 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 = array_slice(array_keys($groesse), 0, self::SERIEN - 1); + $eigene = $sammelt ? array_slice($rangfolge, 0, self::SERIEN - 1) : $rangfolge; sort($eigene); - $rest = array_fill(0, $spalten, 0.0); + $rest = array_fill(0, $spalten, 0.0); $restKonten = 0; - $ergebnis = []; + $ergebnis = []; foreach ($konten as $nummer => $konto) { if (in_array($nummer, $eigene, true)) { @@ -124,17 +159,25 @@ class Vermoegensentwicklung 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++; } - $ergebnis[] = [ - 'id' => 0, - 'name' => 'Übrige Konten (' . $restKonten . ')', - 'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest), - ]; + if ($restKonten > 0) { + $ergebnis[] = [ + 'id' => 0, + 'name' => 'Übrige Konten (' . $restKonten . ')', + 'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest), + ]; + } return $ergebnis; } diff --git a/src/Views/cockpit/vermoegensdiagramm.php b/src/Views/cockpit/vermoegensdiagramm.php index e343d8c..1c0e3e5 100644 --- a/src/Views/cockpit/vermoegensdiagramm.php +++ b/src/Views/cockpit/vermoegensdiagramm.php @@ -16,13 +16,17 @@ use Finanzmanager\View; * 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 + * 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. @@ -44,29 +48,25 @@ foreach ($monate as $nummer => $monat) { $skala = Vermoegensentwicklung::skala($grenzwerte); -// Maße in echten Bildpunkten: Das SVG trägt Breite und Höhe als Attribute und -// wird deshalb nicht auf die Kachel gestreckt. Ein Balken ist 18 Punkte breit, -// zwischen zwei Monaten bleiben 3 – erst unterhalb der eigenen Breite skaliert -// die Zeichnung mit (max-width in app.css). -$balken = 18; -$luecke = 3; -$links = 52; -$rechts = 6; -$oben = 10; -$unten = 32; - -$spalte = $balken + $luecke; -$plotB = $spalte * $anzahl; -$plotH = 150; -$breite = $links + $plotB + $rechts; -$hoehe = $oben + $plotH + $unten; +// 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); }; -$mitte = static function (int $nummer) use ($links, $spalte): float { - return round($links + ($nummer + 0.5) * $spalte, 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 { @@ -82,60 +82,70 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz'] ?> <div class="diagrammbereich"> -<svg class="diagramm" width="<?= $breite ?>" height="<?= $hoehe ?>" - viewBox="0 0 <?= $breite ?> <?= $hoehe ?>" role="img" - preserveAspectRatio="xMidYMid meet" - aria-label="Kontostände je Monatsende, <?= View::h($zeitraum) ?>"> - <title>Vermögensentwicklung <?= View::h($zeitraum) ?> +
+ - - - - + + + Vermögensentwicklung - + + + - $monat): ?> - - $konto): ?> + + + $monat): ?> 0) { - $von = $oberkante; - $bis = $oberkante + $wert; - $oberkante = $bis; - } else { - $von = $unterkante + $wert; - $bis = $unterkante; - $unterkante = $von; - } + $oberkante = 0.0; + $unterkante = 0.0; ?> - - - - + + + $konto): ?> + " y="" - text-anchor="middle"> - - - - - + if ($wert > 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)) ?> + + + + + + + + + + +
    $konto): ?> @@ -143,8 +153,23 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz'] der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. */ ?>
  • - + + + + +
+ + 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 index 4b0a130..1880b81 100644 --- a/tests/13_VermoegensentwicklungTest.php +++ b/tests/13_VermoegensentwicklungTest.php @@ -31,6 +31,7 @@ class VermoegensentwicklungTest extends TestCase $this->summenkennzeichenFiltertNicht(); $this->schalterFiltertStillgelegteKonten(); $this->mehrKontenAlsFarbenWerdenZusammengefasst(); + $this->ebenenBlaetternDurchDieUebrigenKonten(); $this->skalaRundetAufLesbareSchritte(); } @@ -54,9 +55,9 @@ class VermoegensentwicklungTest extends TestCase $this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe'); } - private function reihe(bool $nurAktive = true): array + private function reihe(bool $nurAktive = true, int $ebene = 0): array { - return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE); + return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE, $ebene); } /** @return array|null */ @@ -238,6 +239,63 @@ class VermoegensentwicklungTest extends TestCase ); } + /** + * „Ü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]); diff --git a/tests/e2e/specs/smoke.spec.js b/tests/e2e/specs/smoke.spec.js index 6fbd491..fa187c6 100644 --- a/tests/e2e/specs/smoke.spec.js +++ b/tests/e2e/specs/smoke.spec.js @@ -72,6 +72,30 @@ test.describe('Grundgerüst', () => { 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)