Skip to content
5 changes: 5 additions & 0 deletions ui/component/or-attribute-barchart/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ tasks.register("prepareUi") {
tasks.register("publishUi") {
dependsOn clean, npmPublish
}

tasks.register('npmTest', Exec) {
dependsOn getYarnInstallTask()
commandLine npmCommand('yarn'), 'run', 'test'
}
2 changes: 1 addition & 1 deletion ui/component/or-attribute-barchart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
],
"scripts": {
"analyze": "npx cem analyze --config ../custom-elements-manifest.config.mjs",
"test": "echo \"No tests\" && exit 0",
"test": "npx tsc -b && npx playwright test",
"prepack": "npx rspack"
},
"author": "OpenRemote",
Expand Down
21 changes: 21 additions & 0 deletions ui/component/or-attribute-barchart/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { defineCtConfig } from "@openremote/test/component.config";

export default defineCtConfig(__dirname, { use: { timezoneId: "Europe/Stockholm" } });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export default defineCtConfig(__dirname, { use: { timezoneId: "Europe/Stockholm" } });
export default defineCtConfig(__dirname);

54 changes: 29 additions & 25 deletions ui/component/or-attribute-barchart/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { createMenuBarItem, type MenuBarItem } from "@openremote/or-vaadin-compo
import { OrVaadinDateTimePicker } from "@openremote/or-vaadin-components/or-vaadin-date-time-picker";
import { when } from "lit/directives/when.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { getChartAxisBounds, getNavigationDuration, shiftTimeframe, type TimeframeDirection } from "./timeframe";
import "@openremote/or-translate";

echarts.use([GridComponent, TooltipComponent, DataZoomComponent, BarChart, CanvasRenderer, UniversalTransition]);
Expand Down Expand Up @@ -502,6 +503,7 @@ export class OrAttributeBarChart extends OrElement {
protected _style!: CSSStyleDeclaration;
protected _startOfPeriod?: number; // Start timestamp of the visible period
protected _endOfPeriod?: number; // End timestamp of the visible period
protected _navigationDuration?: number;
protected _latestError?: string;
protected _dataAbortController?: AbortController;
protected _containerResizeObserver?: ResizeObserver;
Expand Down Expand Up @@ -562,6 +564,17 @@ export class OrAttributeBarChart extends OrElement {
const dates: [Date, Date] = this._getTimeSelectionDates(this.timePrefixKey!, this.timeWindowKey!);
this._startOfPeriod = this.timeframe ? this.timeframe[0].getTime() : dates[0].getTime();
this._endOfPeriod = this.timeframe ? this.timeframe[1].getTime() : dates[1].getTime();
const timeWindow = this.timeWindowOptions.get(this.timeWindowKey!);
if (!timeWindow) {
throw new Error(`Unsupported time window selected: ${this.timeWindowKey}`);
}
this._navigationDuration = getNavigationDuration(
this._startOfPeriod,
this._endOfPeriod,
this._isCustomWindow,
timeWindow[0],
timeWindow[1]
);
this._intervalConfig = this._getInterval(this._startOfPeriod, this._endOfPeriod, this.interval!);
this._loadData();
}
Expand Down Expand Up @@ -982,6 +995,7 @@ export class OrAttributeBarChart extends OrElement {
const recommendedTicks = this._chartElem?.clientWidth ? this._chartElem.clientWidth / 50 : Number.MAX_SAFE_INTEGER;
const maxTicks = Math.floor(recommendedTicks * 1.5);
const splitNumber = Math.max(1, Math.min(xAxisTicks, maxTicks));
const [axisMin, axisMax] = getChartAxisBounds(this._startOfPeriod!, this._endOfPeriod!, this._data);

@Ekhorn Ekhorn Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axisMin and axisMax look unused here, also getChartAxisBounds doesn't do anything.

Suggested change
const [axisMin, axisMax] = getChartAxisBounds(this._startOfPeriod!, this._endOfPeriod!, this._data);

this._chart?.setOption({
xAxis: {
show: splitNumber > 1,
Expand Down Expand Up @@ -1049,19 +1063,21 @@ export class OrAttributeBarChart extends OrElement {
return [startDate.toDate(), endDate.toDate()];
}

protected _shiftTimeframe(currentStart: Date, currentEnd: Date, timeWindowSelected: string, direction: string) {
const timeWindow = this.timeWindowOptions.get(timeWindowSelected);

if (!timeWindow) {
protected _shiftTimeframe(
currentStart: Date,
currentEnd: Date,
timeWindowSelected: string,
direction: TimeframeDirection
) {
if (!this.timeWindowOptions.has(timeWindowSelected)) {
throw new Error(`Unsupported time window selected: ${timeWindowSelected}`);
}

const [unit, value] = timeWindow;
const newStart = moment(currentStart);
direction === "previous" ? newStart.subtract(value, unit) : newStart.add(value, unit);
const newEnd = moment(currentEnd);
direction === "previous" ? newEnd.subtract(value, unit) : newEnd.add(value, unit);
this.timeframe = [newStart.toDate(), newEnd.toDate()];
const [unit, value] = this.timeWindowOptions.get(timeWindowSelected)!;
const duration =
this._navigationDuration ??
getNavigationDuration(currentStart.getTime(), currentEnd.getTime(), this._isCustomWindow, unit, value);
this.timeframe = shiftTimeframe(currentStart, currentEnd, duration, unit, value, direction, this._isCustomWindow);
}

protected _getInterval(
Expand Down Expand Up @@ -1374,34 +1390,22 @@ export class OrAttributeBarChart extends OrElement {
console.error("Could not update bar chart data; the bar chart is not initialized yet.");
return;
}
// When data retrieved from HTTP API uses different start-end times, update them.
// For example, if 'endOfPeriod' is 18:03, but the interval is 15 min, the latest API datapoint will be from 18:15.
const firstEntry = this._data?.[0]?.data as [number, number][] | undefined;
if (firstEntry) {
const firstTimestamp = firstEntry[0][0];
if (firstTimestamp !== this._startOfPeriod) {
this._startOfPeriod = firstTimestamp;
}
const endTimestamp = [...firstEntry].reverse()[0][0];
if (endTimestamp !== this._endOfPeriod) {
this._endOfPeriod = endTimestamp;
}
}

// Update ticks / labels
const xAxisTicks = Math.max(1, (this._endOfPeriod! - this._startOfPeriod!) / this._intervalConfig!.millis - 1);
const recommendedTicks = this._chartElem?.clientWidth ? this._chartElem.clientWidth / 50 : Number.MAX_SAFE_INTEGER;
const maxTicks = Math.floor(recommendedTicks * 1.5);
const splitNumber = Math.max(1, Math.min(xAxisTicks, maxTicks));
const [axisMin, axisMax] = getChartAxisBounds(this._startOfPeriod!, this._endOfPeriod!, this._data);

// Update chart
this._chart.setOption({
xAxis: {
show: splitNumber > 1,
splitNumber,
minInterval: this._intervalConfig!.millis,
min: this._startOfPeriod,
max: this._endOfPeriod,
min: axisMin,
max: axisMax,
axisLabel: {
interval: this._intervalConfig?.millis,
rotate: splitNumber > recommendedTicks ? 45 : 0,
Expand Down
74 changes: 74 additions & 0 deletions ui/component/or-attribute-barchart/src/timeframe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import moment from "moment";

export type TimeframeDirection = "previous" | "next";

export type TimeframeUnit = moment.unitOfTime.DurationConstructor;

const CALENDAR_UNITS = new Set(["day", "week", "month", "quarter", "year"]);

function normalizeUnit(unit: TimeframeUnit): string {
return (moment as typeof moment & { normalizeUnits: (value: TimeframeUnit) => string }).normalizeUnits(unit);
}

export function getChartAxisBounds(start: number, end: number, _dataset?: unknown): [number, number] {
return [start, end];
}

Comment on lines +31 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function doesn't do anything. Please remove it.

Suggested change
export function getChartAxisBounds(start: number, end: number, _dataset?: unknown): [number, number] {
return [start, end];
}

export function getNavigationDuration(
start: number,
end: number,
isCustomTimeframe: boolean,
unit: TimeframeUnit,
value: number
): number {
return isCustomTimeframe ? end - start : moment.duration(value, unit).asMilliseconds();
}

export function shiftTimeframeByDuration(
currentStart: Date,
currentEnd: Date,
duration: number,
direction: TimeframeDirection
): [Date, Date] {
const offset = direction === "previous" ? -duration : duration;
return [new Date(currentStart.getTime() + offset), new Date(currentEnd.getTime() + offset)];
}

export function shiftTimeframe(
currentStart: Date,
currentEnd: Date,
duration: number,
unit: TimeframeUnit,
value: number,
direction: TimeframeDirection,
isCustomTimeframe = false
): [Date, Date] {
const normalizedUnit = normalizeUnit(unit);
if (isCustomTimeframe || !CALENDAR_UNITS.has(normalizedUnit)) {
return shiftTimeframeByDuration(currentStart, currentEnd, duration, direction);
}

const start = moment(currentStart);
Comment thread
wborn marked this conversation as resolved.
Resolved
const end = moment(currentEnd);
direction === "previous" ? start.subtract(value, unit) : start.add(value, unit);
direction === "previous" ? end.subtract(value, unit) : end.add(value, unit);
return [start.toDate(), end.toDate()];
}
Loading
Loading