fix: add KPI tile widget type to the dashboard (#26)
CI / build-and-test (pull_request) Successful in 2m24s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
This commit is contained in:
Pit Friedrich
2026-07-28 19:57:55 +02:00
parent 1b846751dd
commit 21834ec9a0
10 changed files with 399 additions and 10 deletions
@@ -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<String, Object> sparklineOptions() {
Map<String, Object> options = baseOptions("line");
@SuppressWarnings("unchecked")
Map<String, Object> chart = (Map<String, Object>) 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;
}
}
+3 -1
View File
@@ -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;
@@ -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}.
* <p>
* 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.
* <p>
* 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<Double> 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) + " %";
}
}
@@ -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<Double> values) {
Map<String, Object> options = DialectTheme.sparklineOptions();
options.put("series", List.of(Map.of("name", seriesName, "data", values)));
sendOptions(options);
}
}
@@ -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<Double> 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"),
@@ -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);
@@ -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}
@@ -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}
@@ -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}
@@ -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<GridStackItem.Position> 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<KpiTile> 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<GridStackItem.Position> 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<GridStackItem.Position> 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();
}
}