Cockpit: Übrige Konten aufklappbar, Balken rücken auseinander

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-11 14:22:06 +02:00
parent 1faf10aeda
commit 048bbf3894
8 changed files with 313 additions and 98 deletions

View file

@ -20,6 +20,14 @@
- Eigenes SVG, keine Diagrammbibliothek. Die Farben stehen als Klasse am Element und - 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>` folgen damit dem hellen wie dem dunklen Thema; jedes Balkenstück trägt ein `<title>`
als Tooltip, ohne eine Zeile JavaScript. 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 - Die Kachel ist so groß wie ihre Nachbarn: Der Ausgriff über zwei Rasterspalten
(`.kachel.breit`) und das engere Cockpit-Raster (`.kachelraster.dicht`) entfallen wieder. (`.kachel.breit`) und das engere Cockpit-Raster (`.kachelraster.dicht`) entfallen wieder.
- Die Legende steht unter der Zeichnung statt daneben und in kleinerer Schrift (.74rem); - Die Legende steht unter der Zeichnung statt daneben und in kleinerer Schrift (.74rem);

View file

@ -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 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. (`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 ### SVG von Hand
Keine Diagrammbibliothek (siehe `PLAN.md`): `src/Views/cockpit/vermoegensdiagramm.php` 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 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. `<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 Senkrecht rechnet die Zeichnung in Bildpunkten, waagerecht in Prozent: Die Balkenmitte von
Monaten bleiben 3. Damit das auch auf dem Schirm so ankommt, trägt das `<svg>` Breite und Monat *i* liegt bei `(i + 0,5) / 12` der Fläche, der Balken selbst ist feste 16 Punkte breit
Höhe als Attribute und wird **nicht** auf die Kachel gestreckt; `max-width: 100%` lässt es und wird per `transform` um seine halbe Breite nach links gerückt. In einer breiten Kachel
erst mitschrumpfen, wenn die Kachel schmaler wird als die Zeichnung – in der dreispaltigen rücken die Balken damit **auseinander**, statt breiter zu werden.
Kachel also auf gut 90 Prozent.
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: 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. 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, * `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände,
Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette, 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 * `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

View file

@ -361,23 +361,30 @@ form.inline { display: inline; margin: 0; }
.serie-11 { --farbe: #e07b91; } .serie-11 { --farbe: #e07b91; }
.serie-12 { --farbe: #7b8a99; } .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 { .diagrammbereich {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start;
gap: .6rem; 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 { .diagramm {
max-width: 100%; flex: 1 1 0;
height: auto; min-width: 0;
display: block; display: block;
overflow: visible; overflow: visible;
flex: 0 1 auto;
} }
.diagramm rect { fill: var(--farbe); } .diagramm rect { fill: var(--farbe); }
@ -443,6 +450,22 @@ form.inline { display: inline; margin: 0; }
white-space: nowrap; 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 ──────────────────────────────────────────────────────── /* ── Ein/Aus-Schalter ────────────────────────────────────────────────────────
Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein

View file

@ -25,10 +25,20 @@ class CockpitController extends BaseController
$nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true); $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', [ $this->render('cockpit/index.php', [
'titel' => 'Cockpit', 'titel' => 'Cockpit',
'uebersicht' => Konto::uebersicht(), 'uebersicht' => Konto::uebersicht(),
'vermoegen' => Vermoegensentwicklung::reihe($nurAktive), 'vermoegen' => Vermoegensentwicklung::reihe(
$nurAktive,
Vermoegensentwicklung::MONATE,
null,
$ebene
),
'nurAktive' => $nurAktive, 'nurAktive' => $nurAktive,
]); ]);
} }

View file

@ -24,17 +24,23 @@ class Vermoegensentwicklung
public const SERIEN = 12; 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{ * @return array{
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>, * monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
* konten: list<array{id:int, name:string, werte:list<float>}>, * konten: list<array{id:int, name:string, werte:list<float>}>,
* summen: list<float>, * summen: list<float>,
* hatWerte: bool * hatWerte: bool,
* ebene: int,
* ebenen: int
* } * }
*/ */
public static function reihe( public static function reihe(
bool $nurAktive = true, bool $nurAktive = true,
int $anzahl = self::MONATE, int $anzahl = self::MONATE,
?string $heute = null ?string $heute = null,
int $ebene = 0
): array { ): array {
$monate = self::monate($anzahl, $heute); $monate = self::monate($anzahl, $heute);
$erster = $monate[0]; $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) { if (count($konten) > self::SERIEN) {
$konten = self::zusammenfassen($konten, count($monate)); $konten = self::zusammenfassen($konten, count($monate), $ebene);
} }
$hatWerte = false; $hatWerte = false;
@ -90,18 +99,38 @@ class Vermoegensentwicklung
'konten' => $konten, 'konten' => $konten,
'summen' => array_values($summen), 'summen' => array_values($summen),
'hatWerte' => $hatWerte || $konten !== [], '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 * 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 * wird zu einem Balkenstück zusammengefasst. Sonst bekämen zwei Konten
* denselben Ton – und wer die Legende liest, ordnete falsch zu. * 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 * @param list<array{id:int, name:string, werte:list<float>}> $konten
* @return list<array{id:int, name:string, werte:list<float>}> * @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 = []; $groesse = [];
foreach ($konten as $nummer => $konto) { foreach ($konten as $nummer => $konto) {
@ -109,14 +138,20 @@ class Vermoegensentwicklung
} }
arsort($groesse); 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 // Die größten behalten, aber in ihrer ursprünglichen Reihenfolge – der
// Stapel soll nicht bei jedem Aufruf anders sortiert sein. // 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); sort($eigene);
$rest = array_fill(0, $spalten, 0.0); $rest = array_fill(0, $spalten, 0.0);
$restKonten = 0; $restKonten = 0;
$ergebnis = []; $ergebnis = [];
foreach ($konten as $nummer => $konto) { foreach ($konten as $nummer => $konto) {
if (in_array($nummer, $eigene, true)) { if (in_array($nummer, $eigene, true)) {
@ -124,17 +159,25 @@ class Vermoegensentwicklung
continue; 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) { foreach ($konto['werte'] as $spalte => $wert) {
$rest[$spalte] += $wert; $rest[$spalte] += $wert;
} }
$restKonten++; $restKonten++;
} }
$ergebnis[] = [ if ($restKonten > 0) {
'id' => 0, $ergebnis[] = [
'name' => 'Übrige Konten (' . $restKonten . ')', 'id' => 0,
'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest), 'name' => 'Übrige Konten (' . $restKonten . ')',
]; 'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest),
];
}
return $ergebnis; return $ergebnis;
} }

View file

@ -16,13 +16,17 @@ use Finanzmanager\View;
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>, * monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
* konten: list<array{id:int, name:string, werte:list<float>}>, * konten: list<array{id:int, name:string, werte:list<float>}>,
* summen: list<float>, * summen: list<float>,
* hatWerte: bool * hatWerte: bool,
* ebene: int,
* ebenen: int
* } $reihe * } $reihe
*/ */
$monate = $reihe['monate']; $monate = $reihe['monate'];
$konten = $reihe['konten']; $konten = $reihe['konten'];
$anzahl = count($monate); $anzahl = count($monate);
$ebene = $reihe['ebene'];
$ebenen = $reihe['ebenen'];
// Die Achse muss den gestapelten Positiv- und Negativteil jedes Balkens fassen. // Die Achse muss den gestapelten Positiv- und Negativteil jedes Balkens fassen.
// Die Nettosumme liegt immer dazwischen und braucht keine eigene Grenze. // Die Nettosumme liegt immer dazwischen und braucht keine eigene Grenze.
@ -44,29 +48,25 @@ foreach ($monate as $nummer => $monat) {
$skala = Vermoegensentwicklung::skala($grenzwerte); $skala = Vermoegensentwicklung::skala($grenzwerte);
// Maße in echten Bildpunkten: Das SVG trägt Breite und Höhe als Attribute und // Senkrecht in echten Bildpunkten, waagerecht in Prozent: Die Zeichenfläche
// wird deshalb nicht auf die Kachel gestreckt. Ein Balken ist 18 Punkte breit, // nimmt die Breite, die die Kachel hergibt, und die Balken rücken darin
// zwischen zwei Monaten bleiben 3 – erst unterhalb der eigenen Breite skaliert // auseinander, statt breiter zu werden. Die Achsenbeschriftung sitzt in einem
// die Zeichnung mit (max-width in app.css). // eigenen, festen SVG daneben – sonst müsste die Prozentrechnung ihre Breite
$balken = 18; // abziehen, und das kann ein SVG-Attribut nicht.
$luecke = 3; $balken = 16;
$links = 52; $achse = 56;
$rechts = 6; $oben = 10;
$oben = 10; $plotH = 150;
$unten = 32; $unten = 32;
$hoehe = $oben + $plotH + $unten;
$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 { $y = static function (float $wert) use ($skala, $oben, $plotH): float {
return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2); return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2);
}; };
$mitte = static function (int $nummer) use ($links, $spalte): float { /** Die Mitte einer Monatsspalte, als Anteil der Zeichenfläche. */
return round($links + ($nummer + 0.5) * $spalte, 2); $mitte = static function (int $nummer) use ($anzahl): string {
return round(($nummer + 0.5) / $anzahl * 100, 3) . '%';
}; };
$achsenzahl = static function (float $wert): string { $achsenzahl = static function (float $wert): string {
@ -82,60 +82,70 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
?> ?>
<div class="diagrammbereich"> <div class="diagrammbereich">
<svg class="diagramm" width="<?= $breite ?>" height="<?= $hoehe ?>" <div class="diagrammflaeche">
viewBox="0 0 <?= $breite ?> <?= $hoehe ?>" role="img" <svg class="diagrammachse" width="<?= $achse ?>" height="<?= $hoehe ?>" aria-hidden="true">
preserveAspectRatio="xMidYMid meet" <?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
aria-label="Kontostände je Monatsende, <?= View::h($zeitraum) ?>"> <text class="diagramm-achse" x="<?= $achse - 8 ?>" y="<?= $y($wert) + 4 ?>"
<title>Vermögensentwicklung <?= View::h($zeitraum) ?></title> text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text>
<?php endfor; ?>
</svg>
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?> <?php /* Ohne viewBox: Die Koordinaten bleiben Bildpunkte, nur die
<line class="diagramm-gitter" x1="<?= $links ?>" x2="<?= $breite - $rechts ?>" Prozentangaben wachsen mit der Fläche. */ ?>
y1="<?= $y($wert) ?>" y2="<?= $y($wert) ?>"></line> <svg class="diagramm" height="<?= $hoehe ?>" role="img"
<text class="diagramm-achse" x="<?= $links - 8 ?>" y="<?= $y($wert) + 4 ?>" aria-label="Kontostände je Monatsende, <?= View::h($zeitraum) ?>">
text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text> <title>Vermögensentwicklung <?= View::h($zeitraum) ?></title>
<?php endfor; ?>
<line class="diagramm-nulllinie" x1="<?= $links ?>" x2="<?= $breite - $rechts ?>" <?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
y1="<?= $y(0.0) ?>" y2="<?= $y(0.0) ?>"></line> <line class="diagramm-gitter" x1="0" x2="100%"
y1="<?= $y($wert) ?>" y2="<?= $y($wert) ?>"></line>
<?php endfor; ?>
<?php foreach ($monate as $nummer => $monat): ?> <line class="diagramm-nulllinie" x1="0" x2="100%"
<?php y1="<?= $y(0.0) ?>" y2="<?= $y(0.0) ?>"></line>
$x = $mitte($nummer) - $balken / 2;
$oberkante = 0.0; <?php foreach ($monate as $nummer => $monat): ?>
$unterkante = 0.0;
?>
<?php foreach ($konten as $index => $konto): ?>
<?php <?php
$wert = $konto['werte'][$nummer]; $oberkante = 0.0;
if (abs($wert) < 0.005) { $unterkante = 0.0;
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) ?>" <?php /* Die Balkenmitte liegt auf dem Prozentwert; die halbe
width="<?= round($balken, 2) ?>" height="<?= round($y($von) - $y($bis), 2) ?>"> Balkenbreite nach links holt die Verschiebung. */ ?>
<title><?= View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr'] <g transform="translate(-<?= $balken / 2 ?> 0)">
. ': ' . View::betrag($wert)) ?></title> <?php foreach ($konten as $index => $konto): ?>
</rect> <?php
<?php endforeach; ?> $wert = $konto['werte'][$nummer];
if (abs($wert) < 0.005) {
continue;
}
<text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 18 ?>" if ($wert > 0) {
text-anchor="middle"><?= View::h($monat['kuerzel']) ?></text> $von = $oberkante;
<?php if ($monat['monat'] === 1 || $nummer === 0): ?> $bis = $oberkante + $wert;
<text class="diagramm-beschriftung schwach" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 5 ?>" $oberkante = $bis;
text-anchor="middle"><?= View::h($monat['jahrKurz']) ?></text> } else {
<?php endif; ?> $von = $unterkante + $wert;
<?php endforeach; ?> $bis = $unterkante;
</svg> $unterkante = $von;
}
?>
<rect class="<?= $serie($index) ?>" x="<?= $mitte($nummer) ?>" y="<?= $y($bis) ?>"
width="<?= $balken ?>" height="<?= round($y($von) - $y($bis), 2) ?>">
<title><?= View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr']
. ': ' . View::betrag($wert)) ?></title>
</rect>
<?php endforeach; ?>
</g>
<text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 18 ?>"
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 - 5 ?>"
text-anchor="middle"><?= View::h($monat['jahrKurz']) ?></text>
<?php endif; ?>
<?php endforeach; ?>
</svg>
</div>
<ul class="diagrammlegende"> <ul class="diagrammlegende">
<?php foreach ($konten as $index => $konto): ?> <?php foreach ($konten as $index => $konto): ?>
@ -143,8 +153,23 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. */ ?> der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. */ ?>
<li> <li>
<span class="legendenfarbe <?= $serie($index) ?>" aria-hidden="true"></span> <span class="legendenfarbe <?= $serie($index) ?>" aria-hidden="true"></span>
<span class="legendenname"><?= View::h($konto['name']) ?></span> <?php if ($konto['id'] === 0): ?>
<a class="legendenname tiefer" href="/?ebene=<?= $ebene + 1 ?>"
title="Diese Konten einzeln zeigen"><?= View::h($konto['name']) ?></a>
<?php else: ?>
<span class="legendenname"><?= View::h($konto['name']) ?></span>
<?php endif; ?>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
<?php if ($ebene > 0): ?>
<p class="diagrammpfad">
Ebene <?= $ebene + 1 ?> von <?= $ebenen ?> ·
<a href="/?ebene=<?= $ebene - 1 ?>">← eine Ebene zurück</a>
<?php if ($ebene > 1): ?>
· <a href="/">zu den größten Konten</a>
<?php endif; ?>
</p>
<?php endif; ?>
</div> </div>

View file

@ -31,6 +31,7 @@ class VermoegensentwicklungTest extends TestCase
$this->summenkennzeichenFiltertNicht(); $this->summenkennzeichenFiltertNicht();
$this->schalterFiltertStillgelegteKonten(); $this->schalterFiltertStillgelegteKonten();
$this->mehrKontenAlsFarbenWerdenZusammengefasst(); $this->mehrKontenAlsFarbenWerdenZusammengefasst();
$this->ebenenBlaetternDurchDieUebrigenKonten();
$this->skalaRundetAufLesbareSchritte(); $this->skalaRundetAufLesbareSchritte();
} }
@ -54,9 +55,9 @@ class VermoegensentwicklungTest extends TestCase
$this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe'); $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<string,mixed>|null */ /** @return array<string,mixed>|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 private function skalaRundetAufLesbareSchritte(): void
{ {
$skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]); $skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]);

View file

@ -72,6 +72,30 @@ test.describe('Grundgerüst', () => {
expect(new Set(farben).size).toBe(farben.length) 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 }) => { test('Schalter „nur aktive“ behält seinen Zustand', async ({ page }) => {
await anmelden(page) await anmelden(page)