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(); } }