Merge pull request 'feat: KPI / stat tile widget type (#26)' (#33) from ai/issue-26-kpi-tile into main

Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
2026-07-28 18:04:47 +00:00
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"),