diff --git a/Changes.md b/Changes.md
index 45fc381..9e07578 100644
--- a/Changes.md
+++ b/Changes.md
@@ -20,6 +20,14 @@
- Eigenes SVG, keine Diagrammbibliothek. Die Farben stehen als Klasse am Element und
folgen damit dem hellen wie dem dunklen Thema; jedes Balkenstück trägt ein `
`
als Tooltip, ohne eine Zeile JavaScript.
+- **Übrige Konten (n)** ist ein Link: Er führt eine Ebene tiefer (`/?ebene=N`) und zeigt
+ die nächstkleineren Konten einzeln – beliebig tief, bis der Rest in die Palette passt.
+ Unter der Legende steht der Weg zurück. Jedes Konto erscheint auf genau einer Ebene, und
+ eine zu hohe Ebene fällt auf die letzte zurück (`Vermoegensentwicklung::ebenen()`).
+- Die Balken nutzen die Kachelbreite aus, indem sie **auseinanderrücken**, nicht indem sie
+ breiter werden: Die Zeichenfläche rechnet waagerecht in Prozent, die Balkenbreite bleibt
+ bei 16 Punkten. Achsenbeschriftung und Zeichenfläche sind dafür zwei SVGs nebeneinander –
+ ein einzelnes könnte von seinen Prozentangaben keine feste Achsenbreite abziehen.
- Die Kachel ist so groß wie ihre Nachbarn: Der Ausgriff über zwei Rasterspalten
(`.kachel.breit`) und das engere Cockpit-Raster (`.kachelraster.dicht`) entfallen wieder.
- Die Legende steht unter der Zeichnung statt daneben und in kleinerer Schrift (.74rem);
diff --git a/docs/cockpit.md b/docs/cockpit.md
index 902bc83..20443f8 100644
--- a/docs/cockpit.md
+++ b/docs/cockpit.md
@@ -56,6 +56,23 @@ zusammengefasst – zwei Konten in derselben Farbe wären in der Legende nicht a
halten. Jede Serie setzt `--farbe`; davon leben der Balken (`fill`) und der Legendenpunkt
(`background`) gleichermaßen, und beide folgen dem hellen wie dem dunklen Thema.
+### Ebenen: in „Übrige Konten“ hineinsehen
+
+Der Legendeneintrag **Übrige Konten (n)** ist ein Link. Er führt auf `/?ebene=1`: Dort
+stehen die nächstelf Konten einzeln, und was dann noch bleibt, sammelt sich erneut. So
+geht es beliebig tief, bis der Rest in die Palette passt – dann entfällt das Sammelstück,
+und das ist die letzte Ebene. `Vermoegensentwicklung::ebenen()` rechnet aus, wie viele es
+sind; eine zu hohe Ebene fällt auf die letzte zurück, statt eine leere Kachel zu zeigen.
+
+Zurück geht es über die Zeile unter der Legende: *eine Ebene zurück* und, ab der dritten,
+*zu den größten Konten*.
+
+Jedes Konto steht auf genau einer Ebene: Was oben schon eine eigene Farbe hatte, ist im
+Sammelstück nicht mehr enthalten. Sonst wüchse der Rest mit jeder Ebene, statt zu
+schrumpfen. Weil auf tieferen Ebenen die großen Konten fehlen, passt sich die Achse an –
+die kleinen Konten werden dadurch überhaupt erst sichtbar. Die Ebene steht in der Adresse
+und nicht in den Einstellungen: Beim nächsten Aufruf beginnt man wieder oben.
+
### SVG von Hand
Keine Diagrammbibliothek (siehe `PLAN.md`): `src/Views/cockpit/vermoegensdiagramm.php`
@@ -67,11 +84,16 @@ Die Achse rundet über `Vermoegensentwicklung::skala()` auf Schritte von 1, 2, 2
mal einer Zehnerpotenz und schließt die Null immer ein. Jedes Balkenstück trägt ein
`` mit Konto, Monat und Betrag – das ist der Tooltip, ohne eine Zeile JavaScript.
-Die Maße stehen in echten Bildpunkten: Ein Balken ist 18 Punkte breit, zwischen zwei
-Monaten bleiben 3. Damit das auch auf dem Schirm so ankommt, trägt das `` Breite und
-Höhe als Attribute und wird **nicht** auf die Kachel gestreckt; `max-width: 100%` lässt es
-erst mitschrumpfen, wenn die Kachel schmaler wird als die Zeichnung – in der dreispaltigen
-Kachel also auf gut 90 Prozent.
+Senkrecht rechnet die Zeichnung in Bildpunkten, waagerecht in Prozent: Die Balkenmitte von
+Monat *i* liegt bei `(i + 0,5) / 12` der Fläche, der Balken selbst ist feste 16 Punkte breit
+und wird per `transform` um seine halbe Breite nach links gerückt. In einer breiten Kachel
+rücken die Balken damit **auseinander**, statt breiter zu werden.
+
+Deshalb sind es zwei SVGs nebeneinander (`.diagrammflaeche`): links die Achsenbeschriftung
+mit fester Breite, rechts die Zeichenfläche. In einem einzigen SVG ginge das nicht –
+Prozentangaben rechnen immer auf die volle Breite, und ein Attribut kann davon keine feste
+Achsenbreite abziehen. Ein `viewBox` verbietet sich aus demselben Grund: Es würde alles
+gleichmäßig strecken, die Balken eingeschlossen.
Die Kachel ist eine gewöhnliche Kachel des Cockpit-Rasters, so breit wie ihre Nachbarn:
Ein Ausgriff über zwei Spalten machte sie zum Fremdkörper zwischen den anderen dreien.
@@ -84,6 +106,8 @@ Tooltip und in der Kachel *Kontostände*; in der Legende kosteten sie nur Breite
* `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände,
Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette,
- Skala
+ Ebenen (jedes Konto genau einmal, letzte Ebene ohne Sammelstück, zu tiefe Ebene fällt
+ zurück), Skala
* `tests/e2e/specs/smoke.spec.js` – die Kachel zeichnet zwölf Balken, keine Farbe der
- Legende kommt doppelt vor, und der Schalter behält seinen Zustand über den Neuaufruf
+ Legende kommt doppelt vor, der Schalter behält seinen Zustand über den Neuaufruf, und
+ *Übrige Konten* führt eine Ebene tiefer und wieder zurück
diff --git a/public/static/app.css b/public/static/app.css
index a852fdd..3599ee2 100644
--- a/public/static/app.css
+++ b/public/static/app.css
@@ -361,23 +361,30 @@ form.inline { display: inline; margin: 0; }
.serie-11 { --farbe: #e07b91; }
.serie-12 { --farbe: #7b8a99; }
-/* Das SVG bringt seine Maße als Attribute mit und wird nicht auf die Kachel
- gestreckt: Nur so sind die 18 Punkte Balkenbreite auch 18 Bildpunkte. Erst
- wenn die Kachel schmaler wird als die Zeichnung, skaliert sie mit – die
- Legende darunter nimmt dann die volle Kachelbreite. */
.diagrammbereich {
display: flex;
flex-direction: column;
- align-items: flex-start;
gap: .6rem;
}
+/* Achsenbeschriftung und Zeichenfläche sind zwei SVGs nebeneinander. Die Achse
+ ist fest, die Fläche nimmt den Rest – und weil deren Balken waagerecht in
+ Prozent sitzen, rücken sie in einer breiten Kachel auseinander, statt breiter
+ zu werden. Ein einziges SVG könnte das nicht: Prozentangaben rechnen immer
+ auf die volle Breite, die feste Achse ließe sich nicht abziehen. */
+.diagrammflaeche {
+ display: flex;
+ align-items: flex-start;
+ width: 100%;
+}
+
+.diagrammachse { flex: none; }
+
.diagramm {
- max-width: 100%;
- height: auto;
+ flex: 1 1 0;
+ min-width: 0;
display: block;
overflow: visible;
- flex: 0 1 auto;
}
.diagramm rect { fill: var(--farbe); }
@@ -443,6 +450,22 @@ form.inline { display: inline; margin: 0; }
white-space: nowrap;
}
+/* „Übrige Konten“ führt eine Ebene tiefer: Der Eintrag ist ein Link und sieht
+ auch danach aus – sonst probierte ihn niemand aus. */
+.legendenname.tiefer {
+ color: var(--akzent);
+ text-decoration: underline dotted;
+ text-underline-offset: 2px;
+}
+
+.legendenname.tiefer:hover { text-decoration: underline; }
+
+.diagrammpfad {
+ margin: 0;
+ font-size: .74rem;
+ color: var(--text-gedaempft);
+}
+
/* ── Ein/Aus-Schalter ────────────────────────────────────────────────────────
Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein
diff --git a/src/Controllers/CockpitController.php b/src/Controllers/CockpitController.php
index e64e8f4..c5956d0 100644
--- a/src/Controllers/CockpitController.php
+++ b/src/Controllers/CockpitController.php
@@ -25,10 +25,20 @@ class CockpitController extends BaseController
$nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true);
+ // Die Ebene steht in der Adresse, nicht in den Einstellungen: Wer in die
+ // „Übrigen Konten“ hineinsieht, will beim nächsten Aufruf wieder oben
+ // anfangen – und kann den Blick trotzdem als Lesezeichen ablegen.
+ $ebene = max(0, (int)$this->query('ebene'));
+
$this->render('cockpit/index.php', [
'titel' => 'Cockpit',
'uebersicht' => Konto::uebersicht(),
- 'vermoegen' => Vermoegensentwicklung::reihe($nurAktive),
+ 'vermoegen' => Vermoegensentwicklung::reihe(
+ $nurAktive,
+ Vermoegensentwicklung::MONATE,
+ null,
+ $ebene
+ ),
'nurAktive' => $nurAktive,
]);
}
diff --git a/src/Services/Vermoegensentwicklung.php b/src/Services/Vermoegensentwicklung.php
index cf790e5..4a521de 100644
--- a/src/Services/Vermoegensentwicklung.php
+++ b/src/Services/Vermoegensentwicklung.php
@@ -24,17 +24,23 @@ class Vermoegensentwicklung
public const SERIEN = 12;
/**
+ * @param int $ebene 0 zeigt die größten Konten, jede weitere Ebene die
+ * jeweils nächstkleineren aus „Übrige Konten“.
+ *
* @return array{
* monate: list,
* konten: list}>,
* summen: list,
- * hatWerte: bool
+ * hatWerte: bool,
+ * ebene: int,
+ * ebenen: int
* }
*/
public static function reihe(
bool $nurAktive = true,
int $anzahl = self::MONATE,
- ?string $heute = null
+ ?string $heute = null,
+ int $ebene = 0
): array {
$monate = self::monate($anzahl, $heute);
$erster = $monate[0];
@@ -75,8 +81,11 @@ class Vermoegensentwicklung
}
}
+ $ebenen = self::ebenen(count($konten));
+ $ebene = max(0, min($ebene, $ebenen - 1));
+
if (count($konten) > self::SERIEN) {
- $konten = self::zusammenfassen($konten, count($monate));
+ $konten = self::zusammenfassen($konten, count($monate), $ebene);
}
$hatWerte = false;
@@ -90,18 +99,38 @@ class Vermoegensentwicklung
'konten' => $konten,
'summen' => array_values($summen),
'hatWerte' => $hatWerte || $konten !== [],
+ 'ebene' => $ebene,
+ 'ebenen' => $ebenen,
];
}
+ /**
+ * Wie viele Ebenen es braucht, um `$konten` Konten einzeln zu zeigen: Jede
+ * Ebene zeigt so viele Konten, wie Farbtöne da sind – eine weniger, wenn
+ * noch ein Stück „Übrige Konten“ dazukommt.
+ */
+ public static function ebenen(int $konten): int
+ {
+ if ($konten <= self::SERIEN) {
+ return 1;
+ }
+
+ return 1 + (int)ceil(($konten - self::SERIEN) / (self::SERIEN - 1));
+ }
+
/**
* Mehr Konten als Farbtöne: Die größten behalten ihre eigene Farbe, der Rest
* wird zu einem Balkenstück zusammengefasst. Sonst bekämen zwei Konten
* denselben Ton – und wer die Legende liest, ordnete falsch zu.
*
+ * `$ebene` blättert in diesem Rest weiter: Ebene 1 überspringt die größten
+ * Konten und zeigt die nächstkleineren einzeln, Ebene 2 die darauf folgenden
+ * und so fort. Passt der Rest in die Palette, entfällt das Sammelstück.
+ *
* @param list}> $konten
* @return list}>
*/
- private static function zusammenfassen(array $konten, int $spalten): array
+ private static function zusammenfassen(array $konten, int $spalten, int $ebene = 0): array
{
$groesse = [];
foreach ($konten as $nummer => $konto) {
@@ -109,14 +138,20 @@ class Vermoegensentwicklung
}
arsort($groesse);
+ $rangfolge = array_slice(array_keys($groesse), $ebene * (self::SERIEN - 1));
+
+ // Passt der Rest vollständig in die Palette, braucht es kein Sammelstück
+ // mehr – dann ist das die letzte Ebene.
+ $sammelt = count($rangfolge) > self::SERIEN;
+
// Die größten behalten, aber in ihrer ursprünglichen Reihenfolge – der
// Stapel soll nicht bei jedem Aufruf anders sortiert sein.
- $eigene = array_slice(array_keys($groesse), 0, self::SERIEN - 1);
+ $eigene = $sammelt ? array_slice($rangfolge, 0, self::SERIEN - 1) : $rangfolge;
sort($eigene);
- $rest = array_fill(0, $spalten, 0.0);
+ $rest = array_fill(0, $spalten, 0.0);
$restKonten = 0;
- $ergebnis = [];
+ $ergebnis = [];
foreach ($konten as $nummer => $konto) {
if (in_array($nummer, $eigene, true)) {
@@ -124,17 +159,25 @@ class Vermoegensentwicklung
continue;
}
+ // Was auf einer früheren Ebene stand, ist hier gar nicht mehr dabei –
+ // sonst wüchse das Sammelstück mit jeder Ebene statt zu schrumpfen.
+ if (!in_array($nummer, $rangfolge, true)) {
+ continue;
+ }
+
foreach ($konto['werte'] as $spalte => $wert) {
$rest[$spalte] += $wert;
}
$restKonten++;
}
- $ergebnis[] = [
- 'id' => 0,
- 'name' => 'Übrige Konten (' . $restKonten . ')',
- 'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest),
- ];
+ if ($restKonten > 0) {
+ $ergebnis[] = [
+ 'id' => 0,
+ 'name' => 'Übrige Konten (' . $restKonten . ')',
+ 'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest),
+ ];
+ }
return $ergebnis;
}
diff --git a/src/Views/cockpit/vermoegensdiagramm.php b/src/Views/cockpit/vermoegensdiagramm.php
index e343d8c..1c0e3e5 100644
--- a/src/Views/cockpit/vermoegensdiagramm.php
+++ b/src/Views/cockpit/vermoegensdiagramm.php
@@ -16,13 +16,17 @@ use Finanzmanager\View;
* monate: list,
* konten: list}>,
* summen: list,
- * hatWerte: bool
+ * hatWerte: bool,
+ * ebene: int,
+ * ebenen: int
* } $reihe
*/
$monate = $reihe['monate'];
$konten = $reihe['konten'];
$anzahl = count($monate);
+$ebene = $reihe['ebene'];
+$ebenen = $reihe['ebenen'];
// Die Achse muss den gestapelten Positiv- und Negativteil jedes Balkens fassen.
// Die Nettosumme liegt immer dazwischen und braucht keine eigene Grenze.
@@ -44,29 +48,25 @@ foreach ($monate as $nummer => $monat) {
$skala = Vermoegensentwicklung::skala($grenzwerte);
-// Maße in echten Bildpunkten: Das SVG trägt Breite und Höhe als Attribute und
-// wird deshalb nicht auf die Kachel gestreckt. Ein Balken ist 18 Punkte breit,
-// zwischen zwei Monaten bleiben 3 – erst unterhalb der eigenen Breite skaliert
-// die Zeichnung mit (max-width in app.css).
-$balken = 18;
-$luecke = 3;
-$links = 52;
-$rechts = 6;
-$oben = 10;
-$unten = 32;
-
-$spalte = $balken + $luecke;
-$plotB = $spalte * $anzahl;
-$plotH = 150;
-$breite = $links + $plotB + $rechts;
-$hoehe = $oben + $plotH + $unten;
+// Senkrecht in echten Bildpunkten, waagerecht in Prozent: Die Zeichenfläche
+// nimmt die Breite, die die Kachel hergibt, und die Balken rücken darin
+// auseinander, statt breiter zu werden. Die Achsenbeschriftung sitzt in einem
+// eigenen, festen SVG daneben – sonst müsste die Prozentrechnung ihre Breite
+// abziehen, und das kann ein SVG-Attribut nicht.
+$balken = 16;
+$achse = 56;
+$oben = 10;
+$plotH = 150;
+$unten = 32;
+$hoehe = $oben + $plotH + $unten;
$y = static function (float $wert) use ($skala, $oben, $plotH): float {
return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2);
};
-$mitte = static function (int $nummer) use ($links, $spalte): float {
- return round($links + ($nummer + 0.5) * $spalte, 2);
+/** Die Mitte einer Monatsspalte, als Anteil der Zeichenfläche. */
+$mitte = static function (int $nummer) use ($anzahl): string {
+ return round(($nummer + 0.5) / $anzahl * 100, 3) . '%';
};
$achsenzahl = static function (float $wert): string {
@@ -82,60 +82,70 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
?>
-
- Vermögensentwicklung = View::h($zeitraum) ?>
+
+
+
+ = View::h($achsenzahl($wert)) ?>
+
+
-
-
- = View::h($achsenzahl($wert)) ?>
-
+
+
+ Vermögensentwicklung = View::h($zeitraum) ?>
-
+
+
+
- $monat): ?>
-
- $konto): ?>
+
+
+ $monat): ?>
0) {
- $von = $oberkante;
- $bis = $oberkante + $wert;
- $oberkante = $bis;
- } else {
- $von = $unterkante + $wert;
- $bis = $unterkante;
- $unterkante = $von;
- }
+ $oberkante = 0.0;
+ $unterkante = 0.0;
?>
-
- = View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr']
- . ': ' . View::betrag($wert)) ?>
-
-
+
+
+ $konto): ?>
+ " y="= $hoehe - 18 ?>"
- text-anchor="middle">= View::h($monat['kuerzel']) ?>
-
- = View::h($monat['jahrKurz']) ?>
-
-
-
+ if ($wert > 0) {
+ $von = $oberkante;
+ $bis = $oberkante + $wert;
+ $oberkante = $bis;
+ } else {
+ $von = $unterkante + $wert;
+ $bis = $unterkante;
+ $unterkante = $von;
+ }
+ ?>
+
+ = View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr']
+ . ': ' . View::betrag($wert)) ?>
+
+
+
+
+ = View::h($monat['kuerzel']) ?>
+
+ = View::h($monat['jahrKurz']) ?>
+
+
+
+
$konto): ?>
@@ -143,8 +153,23 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. */ ?>
- = View::h($konto['name']) ?>
+
+ = View::h($konto['name']) ?>
+
+ = View::h($konto['name']) ?>
+
+
+ 0): ?>
+
+ Ebene = $ebene + 1 ?> von = $ebenen ?> ·
+ ← eine Ebene zurück
+ 1): ?>
+ · zu den größten Konten
+
+
+
diff --git a/tests/13_VermoegensentwicklungTest.php b/tests/13_VermoegensentwicklungTest.php
index 4b0a130..1880b81 100644
--- a/tests/13_VermoegensentwicklungTest.php
+++ b/tests/13_VermoegensentwicklungTest.php
@@ -31,6 +31,7 @@ class VermoegensentwicklungTest extends TestCase
$this->summenkennzeichenFiltertNicht();
$this->schalterFiltertStillgelegteKonten();
$this->mehrKontenAlsFarbenWerdenZusammengefasst();
+ $this->ebenenBlaetternDurchDieUebrigenKonten();
$this->skalaRundetAufLesbareSchritte();
}
@@ -54,9 +55,9 @@ class VermoegensentwicklungTest extends TestCase
$this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe');
}
- private function reihe(bool $nurAktive = true): array
+ private function reihe(bool $nurAktive = true, int $ebene = 0): array
{
- return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE);
+ return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE, $ebene);
}
/** @return array|null */
@@ -238,6 +239,63 @@ class VermoegensentwicklungTest extends TestCase
);
}
+ /**
+ * „Übrige Konten“ ist keine Sackgasse: Jede Ebene zeigt die nächstkleineren
+ * Konten einzeln, bis der Rest in die Palette passt.
+ */
+ private function ebenenBlaetternDurchDieUebrigenKonten(): void
+ {
+ $this->assertEquals(1, Vermoegensentwicklung::ebenen(12), 'Was in die Palette passt, hat eine Ebene');
+ $this->assertEquals(2, Vermoegensentwicklung::ebenen(13), 'Ein Konto zu viel macht die zweite Ebene auf');
+ $this->assertEquals(2, Vermoegensentwicklung::ebenen(23), 'Elf eigene und zwölf übrige: noch zwei Ebenen');
+ $this->assertEquals(3, Vermoegensentwicklung::ebenen(24), 'Eines mehr, und es braucht eine dritte');
+
+ // Aus dem vorigen Test stehen mehr Konten da, als die Palette Töne hat.
+ $erste = $this->reihe(true, 0);
+ $zweite = $this->reihe(true, 1);
+
+ $this->assertGreaterThan(1, $erste['ebenen'], 'Der Testbestand füllt mehr als eine Ebene');
+ $this->assertEquals(0, $erste['ebene'], 'Ohne Angabe steht man auf der ersten Ebene');
+ $this->assertEquals(1, $zweite['ebene'], 'Die zweite Ebene ist erreichbar');
+
+ $ids = static fn(array $reihe): array => array_map(
+ static fn(array $konto): int => $konto['id'],
+ $reihe['konten']
+ );
+
+ $this->assert(in_array(0, $ids($erste), true), 'Die erste Ebene sammelt den Rest ein');
+ $this->assertEquals(
+ [],
+ array_intersect(array_diff($ids($erste), [0]), $ids($zweite)),
+ 'Kein Konto steht auf zwei Ebenen'
+ );
+
+ // Die letzte Ebene braucht kein Sammelstück mehr – sonst käme man nie an.
+ $letzte = $this->reihe(true, $erste['ebenen'] - 1);
+ $this->assert(!in_array(0, $ids($letzte), true), 'Auf der letzten Ebene stehen nur noch echte Konten');
+
+ // Zusammen ergeben alle Ebenen wieder den ganzen Bestand.
+ $gesehen = [];
+ for ($ebene = 0; $ebene < $erste['ebenen']; $ebene++) {
+ $gesehen = array_merge($gesehen, array_diff($ids($this->reihe(true, $ebene)), [0]));
+ }
+ $this->assertEquals(
+ count($gesehen),
+ count(array_unique($gesehen)),
+ 'Über alle Ebenen hinweg taucht kein Konto doppelt auf'
+ );
+ $this->assertGreaterThan(
+ Vermoegensentwicklung::SERIEN,
+ count($gesehen),
+ 'Und in Summe sind es mehr Konten, als eine Ebene fasst'
+ );
+
+ // Eine Ebene jenseits der letzten gibt es nicht: Sie fällt darauf zurück.
+ $zuTief = $this->reihe(true, 99);
+ $this->assertEquals($erste['ebenen'] - 1, $zuTief['ebene'], 'Zu tief geraten landet man auf der letzten');
+ $this->assertEquals($ids($letzte), $ids($zuTief), 'Mit denselben Konten');
+ }
+
private function skalaRundetAufLesbareSchritte(): void
{
$skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]);
diff --git a/tests/e2e/specs/smoke.spec.js b/tests/e2e/specs/smoke.spec.js
index 6fbd491..fa187c6 100644
--- a/tests/e2e/specs/smoke.spec.js
+++ b/tests/e2e/specs/smoke.spec.js
@@ -72,6 +72,30 @@ test.describe('Grundgerüst', () => {
expect(new Set(farben).size).toBe(farben.length)
})
+ test('„Übrige Konten“ führt eine Ebene tiefer und wieder zurück', async ({ page }) => {
+ await anmelden(page)
+
+ const kachel = page.locator('.kachel', { hasText: 'Vermögensentwicklung' })
+ const sammel = kachel.locator('.legendenname.tiefer')
+
+ // Der Testbestand hat mehr Konten als Farbtöne; sonst gäbe es nichts
+ // aufzuklappen und der Test hätte hier nichts zu prüfen.
+ await expect(sammel).toHaveText(/Übrige Konten \(\d+\)/)
+
+ const oben = await kachel.locator('.legendenname').allTextContents()
+ await sammel.click()
+ await expect(page).toHaveURL(/ebene=1/)
+ await expect(kachel.locator('.diagrammpfad')).toContainText('Ebene 2 von')
+
+ // Eine Ebene tiefer stehen andere Konten in der Legende.
+ const unten = await kachel.locator('.legendenname').allTextContents()
+ expect(unten.filter(name => oben.includes(name) && !name.startsWith('Übrige'))).toHaveLength(0)
+
+ await kachel.locator('.diagrammpfad a').first().click()
+ await expect(page).toHaveURL(/ebene=0/)
+ expect(await kachel.locator('.legendenname').allTextContents()).toEqual(oben)
+ })
+
test('Schalter „nur aktive“ behält seinen Zustand', async ({ page }) => {
await anmelden(page)