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
+`` 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> 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 @@
+,
+ * konten: list}>,
+ * summen: list,
+ * 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}> $konten
+ * @return list}>
+ */
+ 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
+ */
+ 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..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>, 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';
?>
@@ -12,10 +15,35 @@ 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.
+