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
+`` 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> 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 @@
+,
+ * konten: list}>,
+ * summen: list,
+ * 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}> $konten
+ * @return list}>
+ */
+ 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
+ */
+ 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 $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>, summe: float} $uebersicht */
+/** @var array{monate: list>, konten: list>, summen: list, hatWerte: bool} $vermoegen */
+/** @var bool $nurAktive */
+/** @var string $csrfToken */
include __DIR__ . '/../layout/kopf.php';
?>
@@ -13,9 +16,33 @@ include __DIR__ . '/../layout/kopf.php';
-
Vermögensentwicklung
-
Zeigt Kontostände über die Zeit, sobald Buchungen
- erfasst sind (Phase 2).
+
+
Vermögensentwicklung
+
+
+
+
+
Kontostände der letzten zwölf Monate, je Monatsende
+ gestapelt – ein Farbton pro Konto.
+
+
+
+ Zeigt die Kontostände über die Zeit, sobald = $nurAktive ? 'aktive ' : '' ?>Konten
+ mit Σ-Kennzeichen Buchungen haben.
+