Cockpit: Vermögensentwicklung als gestapeltes Balkendiagramm

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 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-11 13:13:56 +02:00
parent d07b85c1f7
commit 896a1e36ae
13 changed files with 1015 additions and 11 deletions

View file

@ -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 `<title>`
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

View file

@ -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).

View file

@ -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

72
docs/cockpit.md Normal file
View file

@ -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

View file

@ -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());

View file

@ -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;

View file

@ -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('/');
}
}

View file

@ -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
{

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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) ?></title>
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
<line class="diagramm-gitter" x1="<?= $links ?>" x2="<?= $breite - $rechts ?>"
y1="<?= $y($wert) ?>" y2="<?= $y($wert) ?>"></line>
<text class="diagramm-achse" x="<?= $links - 8 ?>" y="<?= $y($wert) + 4 ?>"
text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text>
<?php endfor; ?>
<line class="diagramm-nulllinie" x1="<?= $links ?>" x2="<?= $breite - $rechts ?>"
y1="<?= $y(0.0) ?>" y2="<?= $y(0.0) ?>"></line>
<?php foreach ($monate as $nummer => $monat): ?>
<?php
$x = $mitte($nummer) - $balken / 2;
$oberkante = 0.0;
$unterkante = 0.0;
?>
<?php foreach ($konten as $index => $konto): ?>
<?php
$wert = $konto['werte'][$nummer];
if (abs($wert) < 0.005) {
continue;
}
if ($wert > 0) {
$von = $oberkante;
$bis = $oberkante + $wert;
$oberkante = $bis;
} else {
$von = $unterkante + $wert;
$bis = $unterkante;
$unterkante = $von;
}
?>
<rect class="<?= $serie($index) ?>" x="<?= round($x, 2) ?>" y="<?= $y($bis) ?>"
width="<?= round($balken, 2) ?>" height="<?= round($y($von) - $y($bis), 2) ?>">
<title><?= View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr']
. ': ' . View::betrag($wert)) ?></title>
</rect>
<?php endforeach; ?>
<text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 14 ?>"
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-anchor="middle"><?= View::h($monat['jahrKurz']) ?></text>
<?php endif; ?>
<?php endforeach; ?>
</svg>
<ul class="diagrammlegende">
<?php foreach ($konten as $index => $konto): ?>
<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>

View file

@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Vermoegensentwicklung;
class VermoegensentwicklungTest extends TestCase
{
protected string $testName = 'Test 13: Vermögensentwicklung';
/** Ein fester Stichtag – sonst hinge das Ergebnis am Tag des Testlaufs. */
private const HEUTE = '2026-08-11';
private int $giro = 0;
private int $sparkonto = 0;
private int $kreditkarte = 0;
private int $auslagen = 0;
private int $kategorie = 0;
public function run(): void
{
$this->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<string,mixed>|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');
}
}

View file

@ -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"]')