From 21834ec9a0a0649a9e18df51ec348a63aa41e9b6 Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Tue, 28 Jul 2026 19:57:55 +0200 Subject: [PATCH] fix: add KPI tile widget type to the dashboard (#26) Adds a non-chart dashboard widget: a large value with its label, an optional signed delta versus the previous period, and an optional sparkline. Four tiles now sit above the charts, 3x1 each. - KpiTile draws no surface of its own and fills the grid item's, styled from the --dialect-* token layer; the delta's up/down colors are new tokens rather than literals, so they follow the light/dark toggle. - Value and delta share one row: stacking them costs a line the tile does not have at its default height of one grid cell. - SparklineChart is a chrome-free line chart built on DialectTheme, so it shares the palette and font with the real charts. - ApexCharts' sparkline.enabled drops the axes on the initial render but updateOptions (how the theme toggle recolors a chart) brings the y-axis labels back; they are hidden in CSS, which reaches the chart SVG since it renders into light DOM. The registry registration the issue asks for is left out: #21 is blocked, so there is no widget picker to register with yet. The tiles are wired into DashboardView the same way the chart widgets are, and their values come from the translation bundle until the data service (#20) lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69 --- .../com/example/components/DialectTheme.java | 21 +++ src/main/java/com/example/components/Fa.java | 4 +- .../java/com/example/components/KpiTile.java | 137 ++++++++++++++++++ .../example/components/SparklineChart.java | 30 ++++ .../java/com/example/views/DashboardView.java | 35 ++++- .../resources/META-INF/resources/styles.css | 106 ++++++++++++++ .../vaadin-i18n/translations.properties | 9 ++ .../vaadin-i18n/translations_en.properties | 9 ++ .../vaadin-i18n/translations_es.properties | 9 ++ .../com/example/views/DashboardViewTest.java | 49 ++++++- 10 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/example/components/KpiTile.java create mode 100644 src/main/java/com/example/components/SparklineChart.java diff --git a/src/main/java/com/example/components/DialectTheme.java b/src/main/java/com/example/components/DialectTheme.java index cee25ad..42c46cc 100644 --- a/src/main/java/com/example/components/DialectTheme.java +++ b/src/main/java/com/example/components/DialectTheme.java @@ -62,4 +62,25 @@ public final class DialectTheme { return options; } + + /** + * Options for a {@link SparklineChart}: ApexCharts' own + * {@code sparkline.enabled} strips axes, grid and legend, so the line fills + * the whole (small) container. Built on top of {@link #baseOptions} rather + * than from scratch, so palette and font stay shared with the real charts. + */ + public static Map sparklineOptions() { + Map options = baseOptions("line"); + + @SuppressWarnings("unchecked") + Map chart = (Map) options.get("chart"); + chart.put("sparkline", Map.of("enabled", true)); + + // Thinner than a full-size line, and no tooltip: a KPI tile is meant to + // be read at a glance, not hovered. + options.put("stroke", Map.of("curve", "smooth", "width", 2)); + options.put("tooltip", Map.of("enabled", false)); + + return options; + } } diff --git a/src/main/java/com/example/components/Fa.java b/src/main/java/com/example/components/Fa.java index 665c2b2..8ada6e1 100644 --- a/src/main/java/com/example/components/Fa.java +++ b/src/main/java/com/example/components/Fa.java @@ -26,7 +26,9 @@ public enum Fa { REMOVE("fa-solid", "fa-trash"), RESET("fa-solid", "fa-arrow-rotate-left"), DRAG("fa-solid", "fa-grip-vertical"), - CLOSE("fa-solid", "fa-xmark"); + CLOSE("fa-solid", "fa-xmark"), + TREND_UP("fa-solid", "fa-arrow-trend-up"), + TREND_DOWN("fa-solid", "fa-arrow-trend-down"); private final String[] classes; diff --git a/src/main/java/com/example/components/KpiTile.java b/src/main/java/com/example/components/KpiTile.java new file mode 100644 index 0000000..e01d5e8 --- /dev/null +++ b/src/main/java/com/example/components/KpiTile.java @@ -0,0 +1,137 @@ +package com.example.components; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.List; +import java.util.Locale; + +/** + * A non-chart dashboard widget: one large value with its label, an optional + * signed delta versus the previous period, and an optional + * {@link SparklineChart}. + *

+ * Deliberately not a {@link Card}: a card contributes its own surface, heading + * and 20px padding, which is more chrome than fits a one-row grid item. The + * tile draws no surface of its own and fills the grid item's instead, styled + * from the same {@code --dialect-*} token layer (see {@code styles.css}) — + * including the delta's up/down colors, which are tokens rather than literals + * so they follow the light/dark toggle. + *

+ * Label and value are passed in already translated; the only text this + * component formats itself is the delta, which is locale-formatted rather than + * translated (a signed number and a percent sign). + */ +public class KpiTile extends Div { + + /** Positive/negative/zero delta modifiers; {@code styles.css} keys the + * delta color off these. */ + private static final String DELTA_CLASS = "dialect-kpi__delta"; + + private final Span value = new Span(); + private final Span label = new Span(); + private final Div delta = new Div(); + /** Value and delta share one row — stacking them costs a line the tile does + * not have at its default height of one grid cell. */ + private final Div figure = new Div(); + private String deltaText; + private SparklineChart sparkline; + + public KpiTile(String label, String value) { + addClassName("dialect-kpi"); + + this.value.addClassName("dialect-kpi__value"); + this.value.setText(value); + + this.label.addClassName("dialect-kpi__label"); + this.label.setText(label); + + figure.addClassName("dialect-kpi__figure"); + figure.add(this.value); + + add(this.label, figure); + } + + public KpiTile setValue(String value) { + this.value.setText(value); + return this; + } + + public String getValue() { + return value.getText(); + } + + public String getLabel() { + return label.getText(); + } + + /** + * Shows the change versus the previous period, as a percentage: an arrow + * plus the signed number, colored by direction. A delta of exactly zero + * gets the neutral treatment rather than an upward arrow. + */ + public KpiTile setDelta(double percent) { + delta.removeAll(); + delta.getClassNames().clear(); + delta.addClassName(DELTA_CLASS); + delta.addClassName(DELTA_CLASS + "--" + direction(percent)); + + if (percent > 0) { + delta.add(Fa.TREND_UP.create()); + } else if (percent < 0) { + delta.add(Fa.TREND_DOWN.create()); + } + deltaText = formatDelta(percent); + delta.add(new Span(deltaText)); + + if (delta.getParent().isEmpty()) { + figure.add(delta); + } + return this; + } + + /** The rendered delta text (without the arrow icon), or {@code null} while + * no delta has been set. Kept as a field rather than read back off the + * element: the text lives in a child span, so the wrapper's own text + * content is empty. */ + public String getDeltaText() { + return deltaText; + } + + /** Adds a trend line below the value, or re-feeds the existing one. */ + public KpiTile setSparkline(String seriesName, List values) { + if (sparkline == null) { + sparkline = new SparklineChart(); + sparkline.setWidthFull(); + sparkline.setHeight("100%"); + + Div wrapper = new Div(sparkline); + wrapper.addClassName("dialect-kpi__sparkline"); + add(wrapper); + } + sparkline.setData(seriesName, values); + return this; + } + + public SparklineChart getSparkline() { + return sparkline; + } + + private static String direction(double percent) { + if (percent > 0) { + return "up"; + } + return percent < 0 ? "down" : "flat"; + } + + /** {@code +12,4 %} / {@code -3,1 %} — the sign is always explicit, since a + * delta without one reads as an absolute value. */ + private String formatDelta(double percent) { + Locale locale = getLocale(); + DecimalFormat format = new DecimalFormat("+0.0;-0.0", + DecimalFormatSymbols.getInstance(locale)); + return format.format(percent) + " %"; + } +} diff --git a/src/main/java/com/example/components/SparklineChart.java b/src/main/java/com/example/components/SparklineChart.java new file mode 100644 index 0000000..54e9d90 --- /dev/null +++ b/src/main/java/com/example/components/SparklineChart.java @@ -0,0 +1,30 @@ +package com.example.components; + +import java.util.List; +import java.util.Map; + +/** + * A chrome-free line chart — no axes, grid, legend or tooltip, just the trend + * shape — meant to sit inside a {@link KpiTile} next to the value it belongs + * to. Not an {@link AxisChart}: there are no categories to label, so the + * axis-based {@code setData} signature would only carry dead arguments. + */ +public class SparklineChart extends ApexChart { + + /** Marker class the axis-suppressing rule in {@code styles.css} keys off. */ + private static final String CLASS_NAME = "dialect-sparkline"; + + public SparklineChart() { + super("line"); + // Element API rather than addClassName: ApexChart is HasSize, not + // HasStyle. + getElement().getClassList().add(CLASS_NAME); + } + + public void setData(String seriesName, List values) { + Map options = DialectTheme.sparklineOptions(); + options.put("series", List.of(Map.of("name", seriesName, "data", values))); + + sendOptions(options); + } +} diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index 3da86f9..1a95826 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -6,6 +6,7 @@ import com.example.components.Card; import com.example.components.Fa; import com.example.components.GridStackItem; import com.example.components.GridStackLayout; +import com.example.components.KpiTile; import com.example.components.LineChart; import com.example.components.PieChart; import com.vaadin.flow.component.Component; @@ -44,14 +45,30 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { grid.addLayoutChangeListener(e -> status.setText( getTranslation("gridstack.status", e.getPositions().size()))); + // KPI tiles first: the numbers a dashboard is read for, above the charts + // that explain them. They are 3x1 — a quarter row each, one cell high. grid.add( - new GridStackItem("revenue-trend", 0, 0, 6, 3, + new GridStackItem("kpi-revenue", 0, 0, 3, 1, + kpiTile("kpi.revenueTotal", 12.4, + List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0))), + new GridStackItem("kpi-orders", 3, 0, 3, 1, + kpiTile("kpi.openOrders", -3.1, + List.of(52.0, 47.0, 49.0, 44.0, 40.0, 38.0))), + new GridStackItem("kpi-customers", 6, 0, 3, 1, + kpiTile("kpi.newCustomers", 8.0, + List.of(74.0, 81.0, 79.0, 95.0, 104.0, 112.0))), + new GridStackItem("kpi-order-value", 9, 0, 3, 1, + kpiTile("kpi.averageOrderValue", 0.0, + List.of(480.0, 492.0, 478.0, 489.0, 483.0, 486.0)))); + + grid.add( + new GridStackItem("revenue-trend", 0, 1, 6, 3, new Card(getTranslation("card.revenueTrend"), lineChart())), - new GridStackItem("revenue-month", 6, 0, 6, 3, + new GridStackItem("revenue-month", 6, 1, 6, 3, new Card(getTranslation("card.revenueByMonth"), barChart())), - new GridStackItem("revenue-region", 0, 3, 5, 3, + new GridStackItem("revenue-region", 0, 4, 5, 3, new Card(getTranslation("card.revenueByRegion"), pieChart())), - new GridStackItem("hint", 5, 3, 7, 3, + new GridStackItem("hint", 5, 4, 7, 3, new Card(getTranslation("card.gridstackHint"), new Paragraph(getTranslation("gridstack.hint"))))); @@ -88,6 +105,16 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { new Card(title, new Paragraph(getTranslation("gridstack.widgetText"))))); } + /** The displayed value comes from the bundle alongside the label, since it + * carries locale-specific formatting (decimal separator, currency, "Mio."). + * Like the chart literals above, it moves to the data service in #20. */ + private KpiTile kpiTile(String labelKey, double delta, List trend) { + String label = getTranslation(labelKey); + return new KpiTile(label, getTranslation(labelKey + ".value")) + .setDelta(delta) + .setSparkline(label, trend); + } + private Component lineChart() { LineChart chart = new LineChart(); chart.setData(getTranslation("chart.revenueSeries"), diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css index d872946..6446ead 100644 --- a/src/main/resources/META-INF/resources/styles.css +++ b/src/main/resources/META-INF/resources/styles.css @@ -11,6 +11,10 @@ --dialect-ink: light-dark(#14171f, #e6eaf0); --dialect-border: light-dark(#e6eaf0, #2b303b); --dialect-radius: 14px; + /* Delta direction (KpiTile). Lightened in dark mode so they keep contrast + against the dark surface instead of going muddy. */ + --dialect-positive: light-dark(#1f9d63, #45c98a); + --dialect-negative: light-dark(#d1453b, #f0736a); /* light-dark() only takes 2 args, so each shadow layer gets its own light-dark() call and the layers are joined below (var() is a text substitution, so the comma-separated shadow list still parses correctly). */ @@ -123,6 +127,108 @@ vaadin-app-layout::part(content) { margin: 0 0 12px 0; } +/* KpiTile (components/KpiTile.java): a one-row widget, so everything is sized + to survive a 120px grid cell — the sparkline is the only flexible part and + collapses first. */ +.dialect-kpi { + display: flex; + flex-direction: column; + /* border-box, or the padding is added on top of the 100% and the tile ends + up taller than the grid item it is supposed to fill. */ + box-sizing: border-box; + height: 100%; + min-height: 0; + gap: 2px; + padding: 12px 16px; + overflow: hidden; +} + +/* Value and delta on one line, baseline-aligned, so a one-cell-high tile still + has room for the sparkline underneath. */ +.dialect-kpi__figure { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.dialect-kpi__label { + font-size: 0.8rem; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--dialect-ink); + opacity: 0.6; + /* Long labels shorten rather than wrapping into a second line, which at + h=1 would push the value out of the tile. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dialect-kpi__value { + font-size: clamp(1.4rem, 4cqw, 2rem); + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.02em; + color: var(--dialect-ink); + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Never shrinks: a truncated delta would be unreadable, so the value gives way + first (it ellipsises). */ +.dialect-kpi__delta { + display: flex; + align-items: center; + flex: none; + gap: 6px; + font-size: 0.85rem; + font-weight: 600; +} + +.dialect-kpi__delta--up { + color: var(--dialect-positive); +} + +.dialect-kpi__delta--down { + color: var(--dialect-negative); +} + +.dialect-kpi__delta--flat { + color: var(--dialect-ink); + opacity: 0.6; +} + +/* ApexCharts' `sparkline.enabled` drops the axes on the initial render, but a + later updateOptions — which is how the theme toggle recolors a chart — brings + the y-axis labels back, clipped to a smudge at the tile's left edge. Hide + them for good; the chart renders into light DOM, so this rule reaches its SVG + (see components/apex-chart.ts). */ +apex-chart.dialect-sparkline .apexcharts-yaxis, +apex-chart.dialect-sparkline .apexcharts-xaxis { + display: none; +} + +/* The only part allowed to shrink: at h=1 there is no room left for it and it + collapses to nothing, at h≥2 it takes the slack. */ +.dialect-kpi__sparkline { + flex: 1 1 0; + min-height: 0; + margin-top: 4px; + overflow: hidden; +} + +/* The tile draws no surface of its own — inside a grid item it fills + grid-stack-item-content, which already provides background, radius and + shadow. This also gives the value's `cqw` font size a container to resolve + against, so a narrow tile scales its number down instead of clipping it. */ +.grid-stack-item-content > .dialect-kpi { + container-type: inline-size; +} + /* Secondary text (e.g. the layout status line in GridStackView). */ .dialect-muted { color: var(--dialect-ink); diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties index b3310a1..6674937 100644 --- a/src/main/resources/vaadin-i18n/translations.properties +++ b/src/main/resources/vaadin-i18n/translations.properties @@ -32,6 +32,15 @@ gridstack.widgetText=Frei platzierbare Karte. gridstack.dragHandle=Verschieben gridstack.close=Schließen +kpi.revenueTotal=Gesamtumsatz +kpi.revenueTotal.value=1,24 Mio. € +kpi.openOrders=Offene Vorgänge +kpi.openOrders.value=38 +kpi.newCustomers=Neue Kunden +kpi.newCustomers.value=112 +kpi.averageOrderValue=Ø Bestellwert +kpi.averageOrderValue.value=486 € + chart.revenueSeries=Umsatz 2026 chart.pointClick=Serie {0}, Punkt {1} chart.sliceClick=Slice {0} diff --git a/src/main/resources/vaadin-i18n/translations_en.properties b/src/main/resources/vaadin-i18n/translations_en.properties index 7688e25..0105b68 100644 --- a/src/main/resources/vaadin-i18n/translations_en.properties +++ b/src/main/resources/vaadin-i18n/translations_en.properties @@ -32,6 +32,15 @@ gridstack.widgetText=Freely placeable card. gridstack.dragHandle=Move gridstack.close=Close +kpi.revenueTotal=Total revenue +kpi.revenueTotal.value=€1.24M +kpi.openOrders=Open orders +kpi.openOrders.value=38 +kpi.newCustomers=New customers +kpi.newCustomers.value=112 +kpi.averageOrderValue=Avg. order value +kpi.averageOrderValue.value=€486 + chart.revenueSeries=Revenue 2026 chart.pointClick=Series {0}, point {1} chart.sliceClick=Slice {0} diff --git a/src/main/resources/vaadin-i18n/translations_es.properties b/src/main/resources/vaadin-i18n/translations_es.properties index b21364e..40f35f2 100644 --- a/src/main/resources/vaadin-i18n/translations_es.properties +++ b/src/main/resources/vaadin-i18n/translations_es.properties @@ -32,6 +32,15 @@ gridstack.widgetText=Tarjeta de colocación libre. gridstack.dragHandle=Mover gridstack.close=Cerrar +kpi.revenueTotal=Ingresos totales +kpi.revenueTotal.value=1,24 M€ +kpi.openOrders=Pedidos abiertos +kpi.openOrders.value=38 +kpi.newCustomers=Nuevos clientes +kpi.newCustomers.value=112 +kpi.averageOrderValue=Valor medio del pedido +kpi.averageOrderValue.value=486 € + chart.revenueSeries=Ingresos 2026 chart.pointClick=Serie {0}, punto {1} chart.sliceClick=Sector {0} diff --git a/src/test/java/com/example/views/DashboardViewTest.java b/src/test/java/com/example/views/DashboardViewTest.java index 2a6c92e..b439ff5 100644 --- a/src/test/java/com/example/views/DashboardViewTest.java +++ b/src/test/java/com/example/views/DashboardViewTest.java @@ -4,6 +4,7 @@ import com.example.Application; import com.example.components.BarChart; import com.example.components.GridStackItem; import com.example.components.GridStackLayout; +import com.example.components.KpiTile; import com.example.components.LineChart; import com.example.components.PieChart; import com.vaadin.browserless.SpringBrowserlessTest; @@ -27,6 +28,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @ViewPackages(classes = DashboardView.class) class DashboardViewTest extends SpringBrowserlessTest { + /** Four KPI tiles plus three charts and the hint card. */ + private static final int DEFAULT_WIDGETS = 8; + @Test void view_rendersGridWithDefaultWidgets() { navigate(DashboardView.class); @@ -35,10 +39,42 @@ class DashboardViewTest extends SpringBrowserlessTest { assertNotNull(grid); List layout = grid.getLayout(); - assertEquals(4, layout.size(), "expected the four default widgets"); + assertEquals(DEFAULT_WIDGETS, layout.size(), "expected the default widgets"); assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0)); } + @Test + void kpiTiles_renderValueAndDelta() { + navigate(DashboardView.class); + + List tiles = $view(KpiTile.class).all(); + assertEquals(4, tiles.size(), "expected the four KPI tiles"); + + KpiTile revenue = tiles.getFirst(); + assertEquals(translate("kpi.revenueTotal"), revenue.getLabel()); + assertEquals(translate("kpi.revenueTotal.value"), revenue.getValue()); + // +12.4 — sign is explicit, decimal separator is the locale's. + assertTrue(revenue.getDeltaText().matches("\\+12[.,]4 %"), + "unexpected delta text: " + revenue.getDeltaText()); + + KpiTile orders = tiles.get(1); + assertTrue(orders.getDeltaText().startsWith("-"), + "a negative delta keeps its minus sign: " + orders.getDeltaText()); + assertNotNull(orders.getSparkline(), "each tile carries a sparkline"); + } + + @Test + void kpiTiles_areOneRowHigh() { + navigate(DashboardView.class); + + List kpis = $view(GridStackLayout.class).first().getLayout() + .stream().filter(p -> p.id().startsWith("kpi-")).toList(); + + assertEquals(4, kpis.size()); + assertTrue(kpis.stream().allMatch(p -> p.w() == 3 && p.h() == 1), + "KPI tiles default to a quarter row, one cell high"); + } + @Test void view_rendersTheThreeCharts() { navigate(DashboardView.class); @@ -73,7 +109,7 @@ class DashboardViewTest extends SpringBrowserlessTest { clickCloseButton(first); List layout = grid.getLayout(); - assertEquals(3, layout.size()); + assertEquals(DEFAULT_WIDGETS - 1, layout.size()); assertFalse(layout.stream().anyMatch(p -> closedId.equals(p.id())), "the closed widget must be gone from the layout"); } @@ -90,7 +126,7 @@ class DashboardViewTest extends SpringBrowserlessTest { item.close(); assertEquals(1, fired[0]); - assertEquals(3, grid.getLayout().size()); + assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size()); } @Test @@ -114,8 +150,11 @@ class DashboardViewTest extends SpringBrowserlessTest { } private Button button(String translationKey) { - String caption = getCurrentView().getElement().getComponent() + return $view(Button.class).withText(translate(translationKey)).first(); + } + + private String translate(String translationKey) { + return getCurrentView().getElement().getComponent() .orElseThrow().getTranslation(translationKey); - return $view(Button.class).withText(caption).first(); } } -- 2.52.0