#73: Summary-Banner unterhalb Fortschrittsbalken nach Laufabschluss

Neue Komponente BatchRunSummaryBanner aggregiert die Ergebnisliste nach
Laufende und zeigt je Kategorie Icon + Anzahl + Text an. Banner verschwindet
beim Start des nächsten Laufs. READY_FOR_AI, PROPOSAL_READY und PROCESSING
werden nicht gezählt (nicht im DocumentCompletionStatus-Enum enthalten);
Reset-Pending-Zeilen werden explizit ausgeschlossen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 12:22:57 +02:00
parent 0fe5359299
commit dc17824e84
3 changed files with 451 additions and 2 deletions
@@ -0,0 +1,202 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
/**
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
* die aggregierten Ergebnisse anzeigt.
*
* <p>Das Banner erscheint nach Laufabschluss unterhalb des Fortschrittsbalkens und
* oberhalb der Ergebnistabelle. Es zeigt nur Kategorien, deren Zähler größer als null
* ist. Folgende Status werden nicht gezählt und tauchen nie im Banner auf:
* {@code READY_FOR_AI}, {@code PROPOSAL_READY} und {@code PROCESSING} sind im
* Enum {@link DocumentCompletionStatus} nicht enthalten alle enthaltenen Werte
* werden gezählt, außer Einträgen mit {@code resetPending=true}, da diese keinen
* abgeschlossenen Zustand darstellen.
*
* <p>Farbe ist niemals das einzige Unterscheidungsmerkmal: Jedes Segment enthält
* ein Icon und einen Text.
*
* <p>Die öffentlichen Methoden {@link #clear()} und {@link #update(Map)} sind
* thread-agnostisch definiert, aber müssen auf dem JavaFX Application Thread aufgerufen
* werden (oder das Banner muss via {@code Platform.runLater} aktualisiert werden).
* Die Aggregations-Hilfsmethode {@link #aggregateCounts(Iterable)} ist vollständig
* unabhängig von JavaFX und kann auf jedem Thread aufgerufen werden.
*/
public final class BatchRunSummaryBanner {
/** Trennzeichen zwischen den Kategoriesegmenten. */
private static final String SEGMENT_SEPARATOR = " · ";
/** Abstand zwischen den Label-Segmenten in Pixeln. */
private static final int SPACING = 0;
/** Innerer Abstand des Containers in Pixeln (oben/unten). */
private static final double PADDING_V = 4.0;
/** Standardfarbe für den Summentext. */
private static final String STYLE_DEFAULT = "-fx-font-size: 12;";
/**
* Alle {@link DocumentCompletionStatus}-Werte, die im Banner angezeigt werden,
* in der verbindlichen Anzeigereihenfolge gemäß Spezifikation.
*/
private static final List<DocumentCompletionStatus> DISPLAYED_ORDER = List.of(
DocumentCompletionStatus.SUCCESS,
DocumentCompletionStatus.FAILED_RETRYABLE,
DocumentCompletionStatus.FAILED_PERMANENT,
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE
);
/** Wurzel-Container des Banners wird in das Tab-Layout eingebettet. */
private final HBox container;
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
private final Label contentLabel;
/**
* Erstellt ein neues, initial unsichtbares Summary-Banner.
*/
public BatchRunSummaryBanner() {
contentLabel = new Label();
contentLabel.setStyle(STYLE_DEFAULT);
contentLabel.setWrapText(false);
container = new HBox(SPACING, contentLabel);
container.setAlignment(Pos.CENTER_LEFT);
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
// Initial unsichtbar, nimmt keinen Platz ein
container.setVisible(false);
container.setManaged(false);
}
// -------------------------------------------------------------------------
// Öffentliche API
// -------------------------------------------------------------------------
/**
* Versteckt das Banner und leert seinen Inhalt.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void clear() {
contentLabel.setText("");
container.setVisible(false);
container.setManaged(false);
}
/**
* Aktualisiert das Banner mit den aggregierten Zählern und macht es sichtbar.
*
* <p>Zeigt nur Kategorien mit Anzahl &gt; 0. Wenn alle Zähler null sind (leerer Lauf),
* wird das Banner versteckt.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param counts Zuordnung von Verarbeitungsstatus zu Anzahl;
* fehlende Status werden als 0 interpretiert; darf nicht null sein
*/
public void update(Map<DocumentCompletionStatus, Integer> counts) {
Objects.requireNonNull(counts, "counts darf nicht null sein");
String text = buildBannerText(counts);
if (text.isEmpty()) {
clear();
return;
}
contentLabel.setText(text);
container.setVisible(true);
container.setManaged(true);
}
/**
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
*
* @return der Container-Knoten; nie null
*/
public HBox getNode() {
return container;
}
// -------------------------------------------------------------------------
// Aggregations-Hilfe (thread-agnostisch, testbar ohne JavaFX)
// -------------------------------------------------------------------------
/**
* Zählt die Anzahl jedes {@link DocumentCompletionStatus} in der übergebenen
* Iterable. Einträge mit {@code resetPending=true} werden ignoriert, da sie
* keinen abgeschlossenen Verarbeitungszustand darstellen.
*
* <p>Diese Methode ist vollständig unabhängig von JavaFX und kann auf jedem
* Thread aufgerufen werden.
*
* @param rows die Ergebniszeilen des Laufs; darf nicht null sein;
* null-Elemente werden übersprungen
* @return eine Map mit der Anzahl je Status; enthält alle anzuzeigenden
* Status (fehlende haben Wert 0); nie null
*/
public static Map<DocumentCompletionStatus, Integer> aggregateCounts(
Iterable<? extends GuiBatchRunResultRow> rows) {
Objects.requireNonNull(rows, "rows darf nicht null sein");
Map<DocumentCompletionStatus, Integer> counts = new EnumMap<>(DocumentCompletionStatus.class);
// Alle anzuzeigenden Status mit 0 vorbelegen
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
counts.put(status, 0);
}
for (GuiBatchRunResultRow row : rows) {
if (row == null) {
continue;
}
// Reset-Pending-Zeilen zählen nicht sie haben noch keinen abgeschlossenen Status
if (row.resetPending()) {
continue;
}
DocumentCompletionStatus status = row.status();
// Nur anzuzeigende Status zählen (entspricht dem Ausschluss von
// Übergangszuständen wie READY_FOR_AI, PROPOSAL_READY, PROCESSING)
if (counts.containsKey(status)) {
counts.merge(status, 1, Integer::sum);
}
}
return counts;
}
// -------------------------------------------------------------------------
// Interne Hilfsmethoden
// -------------------------------------------------------------------------
/**
* Erzeugt den angezeigten Bannertext aus den Zählern.
* Liefert einen leeren String wenn alle Zähler null sind.
*
* @param counts die Zähler je Status; darf nicht null sein
* @return der fertige Bannertext oder ein leerer String
*/
static String buildBannerText(Map<DocumentCompletionStatus, Integer> counts) {
List<String> segments = new ArrayList<>();
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
int count = counts.getOrDefault(status, 0);
if (count > 0) {
String icon = ProcessingStatusPresentation.iconFor(status);
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
segments.add(icon + " " + count + " " + category);
}
}
return String.join(SEGMENT_SEPARATOR, segments);
}
}
@@ -199,6 +199,9 @@ public final class GuiBatchRunTab {
/** PDF-Vorschau-Komponente im Detailbereich. */
private final PdfPreviewPane pdfPreview = new PdfPreviewPane();
/** Summary-Banner unterhalb des Fortschrittsbalkens sichtbar nach Laufabschluss. */
private final BatchRunSummaryBanner summaryBanner = new BatchRunSummaryBanner();
private final Supplier<Path> configPathSupplier;
private final BooleanSupplier savedConfigurationReadyCheck;
private final Runnable onRunStateChanged;
@@ -501,8 +504,14 @@ public final class GuiBatchRunTab {
HBox.setHgrow(progressBar, Priority.ALWAYS);
counterLabel.setId("batch-run-counter");
HBox header = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
header.setAlignment(Pos.CENTER_LEFT);
HBox progressRow = new HBox(SECONDARY_SPACING, progressBar, counterLabel);
progressRow.setAlignment(Pos.CENTER_LEFT);
// Summary-Banner unterhalb des Fortschrittsbalkens, oberhalb der Tabelle
HBox bannerNode = summaryBanner.getNode();
bannerNode.setId("batch-run-summary-banner");
VBox header = new VBox(0, progressRow, bannerNode);
header.setPadding(new Insets(0, 0, SECONDARY_SPACING, 0));
return header;
}
@@ -1187,6 +1196,7 @@ public final class GuiBatchRunTab {
messageArea.setVisible(false);
messageArea.setManaged(false);
messageArea.setStyle(null);
summaryBanner.clear();
resetMetrics();
updateCounterLabel();
progressBar.setProgress(0);
@@ -1580,6 +1590,10 @@ public final class GuiBatchRunTab {
miniRunCompletedFingerprints = new HashSet<>();
}
selectedRows.clear();
// Summary-Banner aus der aktuellen Ergebnisliste aggregieren und anzeigen
Map<DocumentCompletionStatus, Integer> counts =
BatchRunSummaryBanner.aggregateCounts(resultItems);
summaryBanner.update(counts);
appendSummary(outcome);
updateButtonStates();
notifyRunStateChanged();