Merge pull request 'feat: data service layer for chart widgets (#20)' (#34) from ai/issue-20-chart-data-service into main

Reviewed-on: #34
This commit was merged in pull request #34.
This commit is contained in:
2026-07-28 18:22:25 +00:00
7 changed files with 256 additions and 41 deletions
@@ -0,0 +1,25 @@
package com.example.data;
import java.util.List;
/**
* The seam between the dashboard widgets and wherever their numbers come from.
* Views ask this service for data instead of holding literals, so a widget can
* be re-fed later (live refresh, a global filter, a per-widget refresh action)
* and so the data logic is testable without a Vaadin component tree.
* <p>
* Deliberately thin: the current implementation
* ({@link InMemoryChartDataService}) is in-memory dummy data. A real backend
* would replace the implementation, not this interface.
*/
public interface ChartDataService {
/** Revenue per month, for the line and bar charts. */
ChartSeries revenueByMonth();
/** Revenue per sales region, for the pie chart. */
ChartSeries revenueByRegion();
/** The KPI tiles, in the order the dashboard lays them out. */
List<KpiData> kpis();
}
@@ -0,0 +1,27 @@
package com.example.data;
import java.util.List;
/**
* One named series of numbers plus the categories they are indexed by — the
* shape every chart on the dashboard is fed with, axis-based or not (a pie
* chart reads the categories as slice labels and ignores the series name).
* <p>
* {@code nameKey} and {@code categoryKeys} are <em>translation keys</em>, not
* display text: the data layer must stay free of a {@code UI} and its locale,
* so resolving them against the bundle is the view's job. Values and keys are
* defensively copied, so a data set handed out by a
* {@link ChartDataService} cannot be modified by its consumer.
*/
public record ChartSeries(String nameKey, List<Double> values, List<String> categoryKeys) {
public ChartSeries {
values = List.copyOf(values);
categoryKeys = List.copyOf(categoryKeys);
if (values.size() != categoryKeys.size()) {
throw new IllegalArgumentException(
"each value needs a category: %d values, %d categories"
.formatted(values.size(), categoryKeys.size()));
}
}
}
@@ -0,0 +1,51 @@
package com.example.data;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Dummy implementation returning the hardcoded numbers the views used to carry
* inline. Everything is a constant, so each call returns the same data — the
* point of the service is the seam, not a persistence layer.
*/
@Service
public class InMemoryChartDataService implements ChartDataService {
private static final List<String> MONTHS = List.of(
"month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun");
private static final List<String> REGIONS = List.of(
"region.north", "region.south", "region.east", "region.west");
private static final ChartSeries REVENUE_BY_MONTH = new ChartSeries(
"chart.revenueSeries", List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), MONTHS);
private static final ChartSeries REVENUE_BY_REGION = new ChartSeries(
"chart.revenueSeries", List.of(30.0, 40.0, 35.0, 50.0), REGIONS);
private static final List<KpiData> KPIS = List.of(
new KpiData("kpi-revenue", "kpi.revenueTotal", 12.4,
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0)),
new KpiData("kpi-orders", "kpi.openOrders", -3.1,
List.of(52.0, 47.0, 49.0, 44.0, 40.0, 38.0)),
new KpiData("kpi-customers", "kpi.newCustomers", 8.0,
List.of(74.0, 81.0, 79.0, 95.0, 104.0, 112.0)),
new KpiData("kpi-order-value", "kpi.averageOrderValue", 0.0,
List.of(480.0, 492.0, 478.0, 489.0, 483.0, 486.0)));
@Override
public ChartSeries revenueByMonth() {
return REVENUE_BY_MONTH;
}
@Override
public ChartSeries revenueByRegion() {
return REVENUE_BY_REGION;
}
@Override
public List<KpiData> kpis() {
return KPIS;
}
}
@@ -0,0 +1,25 @@
package com.example.data;
import java.util.List;
/**
* The numbers behind one KPI tile: the change versus the previous period and
* the trend the sparkline draws.
* <p>
* {@code id} identifies the KPI itself (the dashboard reuses it as the grid
* item's {@code gs-id}, which must stay stable across reloads for a saved
* layout to match it again), while {@code labelKey} is a translation key — the
* displayed value is looked up as {@code labelKey + ".value"}, because it
* carries locale-specific formatting (decimal separator, currency, "Mio.").
*/
public record KpiData(String id, String labelKey, double deltaPercent, List<Double> trend) {
public KpiData {
trend = List.copyOf(trend);
}
/** The bundle key of the pre-formatted display value. */
public String valueKey() {
return labelKey + ".value";
}
}
@@ -1,6 +1,7 @@
package com.example.views;
import com.example.components.ApexChart;
import com.example.components.AxisChart;
import com.example.components.BarChart;
import com.example.components.Card;
import com.example.components.Fa;
@@ -9,6 +10,9 @@ import com.example.components.GridStackLayout;
import com.example.components.KpiTile;
import com.example.components.LineChart;
import com.example.components.PieChart;
import com.example.data.ChartDataService;
import com.example.data.ChartSeries;
import com.example.data.KpiData;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
@@ -27,17 +31,26 @@ import java.util.List;
* layout is persisted per browser, plus controls to add widgets and reset the
* layout at runtime. Widgets are removed by their own close button (see
* {@link GridStackItem#setClosable(boolean)}), not from the toolbar.
* <p>
* All widget numbers come from {@link ChartDataService}; this view only decides
* where a widget sits and resolves the data's translation keys against the
* bundle.
*/
@Route("")
public class DashboardView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "dashboard";
/** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
private static final int KPI_WIDTH = 3;
private final ChartDataService dataService;
private final GridStackLayout grid = new GridStackLayout();
private final Span status = new Span();
private int extraWidgetCount;
public DashboardView() {
public DashboardView(ChartDataService dataService) {
this.dataService = dataService;
addClassName("dialect-content");
grid.setWidthFull();
@@ -46,20 +59,15 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
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("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))));
// that explain them. They are 3x1 — a quarter row each, one cell high,
// laid out left to right in the order the service returns them. The
// grid id is the KPI's own id, so it survives reordering.
List<KpiData> kpis = dataService.kpis();
for (int i = 0; i < kpis.size(); i++) {
KpiData kpi = kpis.get(i);
grid.add(new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1,
kpiTile(kpi)));
}
grid.add(
new GridStackItem("revenue-trend", 0, 1, 6, 3,
@@ -106,38 +114,37 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
}
/** 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);
* carries locale-specific formatting (decimal separator, currency, "Mio.");
* the service supplies the key, not the formatted text. */
private KpiTile kpiTile(KpiData kpi) {
String label = getTranslation(kpi.labelKey());
return new KpiTile(label, getTranslation(kpi.valueKey()))
.setDelta(kpi.deltaPercent())
.setSparkline(label, kpi.trend());
}
private Component lineChart() {
LineChart chart = new LineChart();
chart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
chart.addPointClickListener(e -> Notification.show(getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
return sizeFull(chart);
return axisChart(new LineChart());
}
private Component barChart() {
BarChart chart = new BarChart();
chart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
return axisChart(new BarChart());
}
private Component axisChart(AxisChart chart) {
ChartSeries series = dataService.revenueByMonth();
chart.setData(getTranslation(series.nameKey()), series.values(),
translate(series.categoryKeys()));
chart.addPointClickListener(e -> Notification.show(getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
return sizeFull(chart);
}
private Component pieChart() {
ChartSeries series = dataService.revenueByRegion();
PieChart chart = new PieChart();
chart.setData(List.of(30.0, 40.0, 35.0, 50.0),
List.of(getTranslation("region.north"), getTranslation("region.south"),
getTranslation("region.east"), getTranslation("region.west")));
// A pie has no series name — its categories are the slice labels.
chart.setData(series.values(), translate(series.categoryKeys()));
chart.addPointClickListener(e -> Notification.show(
getTranslation("chart.sliceClick", e.getDataPointIndex())));
return sizeFull(chart);
@@ -152,11 +159,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
return chart;
}
private List<String> months() {
return List.of(
getTranslation("month.jan"), getTranslation("month.feb"),
getTranslation("month.mar"), getTranslation("month.apr"),
getTranslation("month.may"), getTranslation("month.jun"));
/** Data sets carry translation keys, not display text — see
* {@link ChartSeries}. */
private List<String> translate(List<String> keys) {
return keys.stream().map(this::getTranslation).toList();
}
@Override
@@ -0,0 +1,73 @@
package com.example.data;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The data layer is plain Java: no Spring context, no Vaadin UI. */
class InMemoryChartDataServiceTest {
private final ChartDataService service = new InMemoryChartDataService();
@Test
void revenueByMonth_hasOneValuePerMonth() {
ChartSeries series = service.revenueByMonth();
assertEquals(List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), series.values());
assertEquals(List.of("month.jan", "month.feb", "month.mar",
"month.apr", "month.may", "month.jun"), series.categoryKeys());
assertEquals("chart.revenueSeries", series.nameKey());
}
@Test
void revenueByRegion_hasOneValuePerRegion() {
ChartSeries series = service.revenueByRegion();
assertEquals(4, series.values().size());
assertEquals(List.of("region.north", "region.south",
"region.east", "region.west"), series.categoryKeys());
}
@Test
void kpis_haveStableUniqueIdsAndATrend() {
List<KpiData> kpis = service.kpis();
assertEquals(4, kpis.size());
Set<String> ids = kpis.stream().map(KpiData::id).collect(Collectors.toSet());
assertEquals(kpis.size(), ids.size(), "grid ids must be unique");
assertTrue(ids.stream().allMatch(id -> id.startsWith("kpi-")));
assertTrue(kpis.stream().allMatch(kpi -> !kpi.trend().isEmpty()),
"each tile draws a sparkline");
}
@Test
void kpi_derivesTheValueKeyFromTheLabelKey() {
KpiData revenue = service.kpis().getFirst();
assertEquals("kpi.revenueTotal", revenue.labelKey());
assertEquals("kpi.revenueTotal.value", revenue.valueKey());
assertEquals(12.4, revenue.deltaPercent());
}
@Test
void series_isImmutableAndBalanced() {
ChartSeries series = service.revenueByMonth();
assertThrows(UnsupportedOperationException.class, () -> series.values().add(1.0));
assertThrows(IllegalArgumentException.class,
() -> new ChartSeries("x", List.of(1.0, 2.0), List.of("only.one")));
}
@Test
void callsAreRepeatable() {
assertEquals(service.revenueByMonth(), service.revenueByMonth());
assertFalse(service.kpis().isEmpty());
}
}
@@ -26,7 +26,7 @@ class DashboardChartPlaywrightTest extends PlaywrightTestBase {
@Test
void dataUpdate_keepsTheRenderedSvgInPlace() {
Locator chart = page.locator("apex-chart").first();
Locator chart = axisChart();
assertThat(chart.locator("svg").first()).isVisible();
// Tag the live SVG node: a full rebuild would replace it and drop the tag.
@@ -43,7 +43,7 @@ class DashboardChartPlaywrightTest extends PlaywrightTestBase {
@Test
void dataUpdate_keepsTheThemeOverlay() {
Locator chart = page.locator("apex-chart").first();
Locator chart = axisChart();
assertThat(chart.locator("svg").first()).isVisible();
chart.evaluate("(el, patch) => el.updateData(patch)", PATCH);
@@ -55,4 +55,12 @@ class DashboardChartPlaywrightTest extends PlaywrightTestBase {
assertTrue(String.valueOf(borderColor).startsWith("rgb"),
"expected a resolved theme color, got: " + borderColor);
}
/** The first chart in the DOM is a KPI sparkline, which has no
* {@code xaxis.categories} — patching one with categories is a structural
* change and legitimately redraws. The patch above is an axis-chart patch,
* so it has to be applied to an axis chart. */
private Locator axisChart() {
return page.locator("apex-chart:not(.dialect-sparkline)").first();
}
}