Cockpit: Übrige Konten aufklappbar, Balken rücken auseinander
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1faf10aeda
commit
048bbf3894
8 changed files with 313 additions and 98 deletions
|
|
@ -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 `<title>`
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`<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
|
||||
Monaten bleiben 3. Damit das auch auf dem Schirm so ankommt, trägt das `<svg>` 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
* 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<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
|
||||
private static function zusammenfassen(array $konten, int $spalten, int $ebene = 0): array
|
||||
{
|
||||
$groesse = [];
|
||||
foreach ($konten as $nummer => $konto) {
|
||||
|
|
@ -109,9 +138,15 @@ 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);
|
||||
|
|
@ -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++;
|
||||
}
|
||||
|
||||
if ($restKonten > 0) {
|
||||
$ergebnis[] = [
|
||||
'id' => 0,
|
||||
'name' => 'Übrige Konten (' . $restKonten . ')',
|
||||
'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest),
|
||||
];
|
||||
}
|
||||
|
||||
return $ergebnis;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,13 +16,17 @@ use Finanzmanager\View;
|
|||
* 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
|
||||
* 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;
|
||||
// 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;
|
||||
$unten = 32;
|
||||
|
||||
$spalte = $balken + $luecke;
|
||||
$plotB = $spalte * $anzahl;
|
||||
$plotH = 150;
|
||||
$breite = $links + $plotB + $rechts;
|
||||
$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,28 +82,36 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
|
|||
?>
|
||||
|
||||
<div class="diagrammbereich">
|
||||
<svg class="diagramm" width="<?= $breite ?>" height="<?= $hoehe ?>"
|
||||
viewBox="0 0 <?= $breite ?> <?= $hoehe ?>" role="img"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
<div class="diagrammflaeche">
|
||||
<svg class="diagrammachse" width="<?= $achse ?>" height="<?= $hoehe ?>" aria-hidden="true">
|
||||
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
|
||||
<text class="diagramm-achse" x="<?= $achse - 8 ?>" y="<?= $y($wert) + 4 ?>"
|
||||
text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text>
|
||||
<?php endfor; ?>
|
||||
</svg>
|
||||
|
||||
<?php /* Ohne viewBox: Die Koordinaten bleiben Bildpunkte, nur die
|
||||
Prozentangaben wachsen mit der Fläche. */ ?>
|
||||
<svg class="diagramm" height="<?= $hoehe ?>" role="img"
|
||||
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 ?>"
|
||||
<line class="diagramm-gitter" x1="0" x2="100%"
|
||||
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 ?>"
|
||||
<line class="diagramm-nulllinie" x1="0" x2="100%"
|
||||
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 /* Die Balkenmitte liegt auf dem Prozentwert; die halbe
|
||||
Balkenbreite nach links holt die Verschiebung. */ ?>
|
||||
<g transform="translate(-<?= $balken / 2 ?> 0)">
|
||||
<?php foreach ($konten as $index => $konto): ?>
|
||||
<?php
|
||||
$wert = $konto['werte'][$nummer];
|
||||
|
|
@ -121,12 +129,13 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
|
|||
$unterkante = $von;
|
||||
}
|
||||
?>
|
||||
<rect class="<?= $serie($index) ?>" x="<?= round($x, 2) ?>" y="<?= $y($bis) ?>"
|
||||
width="<?= round($balken, 2) ?>" height="<?= round($y($von) - $y($bis), 2) ?>">
|
||||
<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>
|
||||
|
|
@ -136,6 +145,7 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
|
|||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<ul class="diagrammlegende">
|
||||
<?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. */ ?>
|
||||
<li>
|
||||
<span class="legendenfarbe <?= $serie($index) ?>" aria-hidden="true"></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>
|
||||
<?php endforeach; ?>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
{
|
||||
$skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue