Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import dayjs from 'dayjs'
import IsoWeek from 'dayjs/plugin/isoWeek'
import Weekday from 'dayjs/plugin/weekday'
import { dimensionFromDate } from './oh-aggregate-series'
import { ChartType, OhAggregateSeries } from '@/types/components/widgets'
import aggregateSeries, { dimensionFromDate } from './oh-aggregate-series'
import { AggregationFunction, ChartType, OhAggregateSeries } from '@/types/components/widgets'
import type { ChartContext } from '../types'

vi.mock('framework7-vue', () => ({
f7: {
utils: {
id: () => 'mock-id'
}
}
}))

dayjs.extend(IsoWeek)
dayjs.extend(Weekday)
Expand Down Expand Up @@ -109,3 +118,96 @@ describe('dimensionFromDate', () => {
})
})
})

describe('aggregateSeries', () => {
const startTime = dayjs('2026-05-12T00:00:00')
const endTime = dayjs('2026-05-12T23:59:59')
const context = {
chart: {
config: {
chartType: ChartType.day
}
},
evaluateExpression: (_key, value) => value,
chartContext: {}
} as unknown as ChartContext

describe('adjustedStartTime', () => {
it('should return same startTime for average aggregation', () => {
const component = {
config: {
aggregationFunction: AggregationFunction.average
}
} as any
expect(aggregateSeries.adjustedStartTime!(context, component, startTime).toISOString()).toBe(startTime.toISOString())
})

describe('Difference aggregations (diffLast, diffFirst)', () => {
it.each([
{ func: AggregationFunction.diffLast, label: 'diffLast' },
{ func: AggregationFunction.diffFirst, label: 'diffFirst' }
])('should return subtracted startTime for $label', ({ func }) => {
const component = {
config: {
aggregationFunction: func
}
} as any
const expected = startTime.subtract(1, 'hour')
expect(aggregateSeries.adjustedStartTime!(context, component, startTime).toISOString()).toBe(expected.toISOString())
})

it('should subtract 1 day if groupStart resolves to day (e.g. for week chart)', () => {
const contextWeek = {
chart: {
config: {
chartType: ChartType.week
}
}
} as any
const component = {
config: {
aggregationFunction: AggregationFunction.diffLast
}
} as any
// ChartType.week => default dimension = weekday => groupStart = 'day'
const expected = startTime.subtract(1, 'day')
expect(aggregateSeries.adjustedStartTime!(contextWeek, component, startTime).toISOString()).toBe(expected.toISOString())
})
})
})

describe('get', () => {
it('should group data correctly and calculate diffLast', () => {
const component = {
config: {
item: 'TestItem',
aggregationFunction: AggregationFunction.diffLast
}
} as any
const points = [
{
name: 'TestItem',
data: [
{ time: startTime.subtract(1, 'hour').valueOf(), state: '100' }, // Look-back point
{ time: startTime.valueOf(), state: '100.7' }, // Hour 0
{ time: startTime.add(1, 'hour').valueOf(), state: '102' } // Hour 1
]
}
] as any

const result = aggregateSeries.get(context, component, points, startTime, endTime)

// result.data should have 2 entries (look-back point should be filtered out)
expect(result.data).toHaveLength(2)
// First group (Hour 0) should be 100.7 - 100 = 0.7
expect(result.data[0][1]).toBe('0.7')
// Second group (Hour 1) should be 102 - 100.7 = 1.3
expect(result.data[1][1]).toBe('1.3')

// Axis positioning (X-axis)
// dimensionFromDate for Hour 0 should return 0
expect(result.data[0][0]).toBe(0)
expect(result.data[1][0]).toBe(1)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,33 @@ function includeBoundaryAndItemStateFor(config: OhAggregateSeries.Config) {
return config.aggregationFunction === OhAggregateSeries.AggregationFunction.diffLast ? true : null
}

/**
* Returns a reasonable default dimension for the given chart type.
* @param chartType
*/
function defaultDimension(chartType: ChartType) {
switch (chartType) {
case ChartType.dynamic:
return undefined
case ChartType.day:
return OhAggregateSeries.Dimension.hour
case ChartType.week:
return OhAggregateSeries.Dimension.weekday
case ChartType.isoWeek:
return OhAggregateSeries.Dimension.isoWeekday
case ChartType.month:
return OhAggregateSeries.Dimension.date
case ChartType.year:
return OhAggregateSeries.Dimension.month
case ChartType.twoYears:
case ChartType.threeYears:
case ChartType.fiveYears:
return OhAggregateSeries.Dimension.year
default:
const exhaustiveCheck: never = chartType
}
}

const aggregateSeries: SeriesComponent = {
neededItems(context, component) {
if (!component || !component.config || !component.config.item) return []
Expand All @@ -73,6 +100,30 @@ const aggregateSeries: SeriesComponent = {
includeItemState(_context, component) {
return includeBoundaryAndItemStateFor(component.config)
},
adjustedStartTime(context, component, startTime) {
if (
component.config.aggregationFunction !== AggregationFunction.diffLast &&
component.config.aggregationFunction !== AggregationFunction.diffFirst
) {
return startTime
}

const chartType = context.chart.config.chartType
let dimension1 = component.config.dimension1 as OhAggregateSeries.Dimension | undefined
if (!dimension1 && chartType) {
dimension1 = defaultDimension(chartType)
}
if (!dimension1) {
dimension1 = chartType as unknown as OhAggregateSeries.Dimension
}
const dimension2 = component.config.dimension2 as OhAggregateSeries.Dimension | undefined
let groupStart: OhAggregateSeries.Dimension | 'day' = dimension2 || dimension1
if (groupStart === OhAggregateSeries.Dimension.weekday || groupStart === OhAggregateSeries.Dimension.isoWeekday || !groupStart) {
groupStart = 'day'
}

return dayjs(startTime).subtract(1, groupStart as dayjs.ManipulateType)
},
get(context, component, points, startTime, endTime) {
const series = context.evaluateExpression<OhAggregateSeriesOption>(
ComponentId.get(component)!,
Expand All @@ -82,30 +133,8 @@ const aggregateSeries: SeriesComponent = {

const chartType = context.chart.config.chartType
let dimension1 = series.dimension1
// if no dimension set: apply reasonable defaults based on chartType
if (!dimension1 && chartType) {
switch (chartType) {
case ChartType.day:
dimension1 = OhAggregateSeries.Dimension.hour
break
case ChartType.week:
dimension1 = OhAggregateSeries.Dimension.weekday
break
case ChartType.isoWeek:
dimension1 = OhAggregateSeries.Dimension.isoWeekday
break
case ChartType.month:
dimension1 = OhAggregateSeries.Dimension.date
break
case ChartType.year:
dimension1 = OhAggregateSeries.Dimension.month
break
case ChartType.twoYears:
case ChartType.threeYears:
case ChartType.fiveYears:
dimension1 = OhAggregateSeries.Dimension.year
break
}
dimension1 = defaultDimension(chartType)
}
if (!dimension1) {
console.warn('oh-aggregate-series: no dimension1 set, falling back to chartType', chartType)
Expand Down Expand Up @@ -149,26 +178,28 @@ const aggregateSeries: SeriesComponent = {
console.debug('oh-aggregate-series: groups', groups)

const formatter = new Intl.NumberFormat('en', { useGrouping: false, maximumFractionDigits: 3 })
const data = groups.map((arr, idx, groups) => {
const aggregationFunction = series.aggregationFunction || AggregationFunction.average
let value: number = aggregate(aggregationFunction, arr, idx, groups)
if (value.toFixed) value = parseFloat(value.toFixed(3))
if (dimension2) {
const axisX = series.transpose ? dimension2 : dimension1
const axisY = series.transpose ? dimension1 : dimension2
return [
dimensionFromDate(chartType, startTime, endTime, arr[0], axisX),
dimensionFromDate(chartType, startTime, endTime, arr[0], axisY, true),
formatter.format(value)
]
} else {
if (series.transpose) {
return [formatter.format(value), dimensionFromDate(chartType, startTime, endTime, arr[0], dimension1, true)]
const data = groups
.map((arr, idx, groups) => {
const aggregationFunction = series.aggregationFunction || AggregationFunction.average
let value: number = aggregate(aggregationFunction, arr, idx, groups)
if (value.toFixed) value = parseFloat(value.toFixed(3))
if (dimension2) {
const axisX = series.transpose ? dimension2 : dimension1
const axisY = series.transpose ? dimension1 : dimension2
return [
dimensionFromDate(chartType, startTime, endTime, arr[0], axisX),
dimensionFromDate(chartType, startTime, endTime, arr[0], axisY, true),
formatter.format(value)
]
} else {
return [dimensionFromDate(chartType, startTime, endTime, arr[0], dimension1), formatter.format(value)]
if (series.transpose) {
return [formatter.format(value), dimensionFromDate(chartType, startTime, endTime, arr[0], dimension1, true)]
} else {
return [dimensionFromDate(chartType, startTime, endTime, arr[0], dimension1), formatter.format(value)]
}
}
}
})
})
.filter((_d, idx) => !groups[idx][0].isBefore(startTime))

if (!series.type) (series.type as unknown as string) = OhAggregateSeries.Type.heatmap

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ export interface SeriesComponent {
get(context: ChartContext, component: api.UiComponent, points: api.ItemHistory[], startTime: Dayjs, endTime: Dayjs): OhSeriesOption
includeBoundary?(context: ChartContext, component: api.UiComponent): boolean | null
includeItemState?(context: ChartContext, component: api.UiComponent): boolean | null
adjustedStartTime?(context: ChartContext, component: api.UiComponent, startTime: Dayjs): Dayjs
}
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ export function useChart(
const combinedPromises = neededItems.map(async (neededItem) => {
let seriesStartTime = startTime.value
let seriesEndTime = endTime.value
if (seriesComponents[component.component].adjustedStartTime) {
seriesStartTime = seriesComponents[component.component].adjustedStartTime!(chartContext.value, component, seriesStartTime)
}
if ('offsetAmount' in config && config.offsetAmount && config.offsetUnit) {
seriesStartTime = seriesStartTime.subtract(config.offsetAmount, config.offsetUnit as dayjs.ManipulateType)
seriesEndTime = seriesEndTime.subtract(config.offsetAmount, config.offsetUnit as dayjs.ManipulateType)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import dayjs from 'dayjs'
import aggregate from './aggregators'
import { AggregationFunction } from '@/types/components/widgets'

describe('aggregators', () => {
const t0 = dayjs('2026-05-12T00:00:00Z')
const t1 = dayjs('2026-05-12T01:00:00Z')
const t2 = dayjs('2026-05-12T02:00:00Z')

describe('diffLast', () => {
it('should return NaN for the first group (idx < 1)', () => {
const groups: [dayjs.Dayjs, string[]][] = [[t0, ['100']]]
expect(aggregate(AggregationFunction.diffLast, groups[0], 0, groups)).toBeNaN()
})

it('should return the difference from the previous group for idx > 0', () => {
const groups: [dayjs.Dayjs, string[]][] = [
[t0, ['100']],
[t1, ['100.7']]
]
expect(aggregate(AggregationFunction.diffLast, groups[1], 1, groups)).toBeCloseTo(0.7)
})

it('should use the LAST value of each group for the difference', () => {
const groups: [dayjs.Dayjs, string[]][] = [
[t0, ['90', '100']],
[t1, ['105', '110.5']]
]
// diffLast = last(current) - last(previous) = 110.5 - 100 = 10.5
expect(aggregate(AggregationFunction.diffLast, groups[1], 1, groups)).toBe(10.5)
})
})

describe('diffFirst', () => {
it('should return NaN for the first group (idx < 1)', () => {
const groups: [dayjs.Dayjs, string[]][] = [[t0, ['100']]]
expect(aggregate(AggregationFunction.diffFirst, groups[0], 0, groups)).toBeNaN()
})

it('should return the difference from the previous group for idx > 0', () => {
const groups: [dayjs.Dayjs, string[]][] = [
[t0, ['100', '105']],
[t1, ['108', '110']]
]
// diffFirst = first(current) - first(previous) = 108 - 100 = 8
expect(aggregate(AggregationFunction.diffFirst, groups[1], 1, groups)).toBe(8)
})
})

describe('Other aggregators', () => {
const group: [dayjs.Dayjs, string[]] = [t0, ['10', '20', '30']]
const groups: [dayjs.Dayjs, string[]][] = [group]

it('should calculate sum', () => {
expect(aggregate(AggregationFunction.sum, group, 0, groups)).toBe(60)
})

it('should calculate min', () => {
expect(aggregate(AggregationFunction.min, group, 0, groups)).toBe(10)
})

it('should calculate max', () => {
expect(aggregate(AggregationFunction.max, group, 0, groups)).toBe(30)
})

it('should calculate average', () => {
expect(aggregate(AggregationFunction.average, group, 0, groups)).toBe(20)
})

it('should calculate first', () => {
expect(aggregate(AggregationFunction.first, group, 0, groups)).toBe(10)
})

it('should calculate last', () => {
expect(aggregate(AggregationFunction.last, group, 0, groups)).toBe(30)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ export default (
case AggregationFunction.diffFirst:
return idx < 1 ? NaN : parseFloat(arr[1][0]) - parseFloat(values[idx - 1][1][0])
case AggregationFunction.diffLast:
if (idx < 1) {
return parseFloat(arr[1][arr[1].length - 1]) - parseFloat(arr[1][0])
} else {
return parseFloat(arr[1][arr[1].length - 1]) - parseFloat(values[idx - 1][1][values[idx - 1][1].length - 1])
}
return idx < 1 ? NaN : parseFloat(arr[1][arr[1].length - 1]) - parseFloat(values[idx - 1][1][values[idx - 1][1].length - 1])
case AggregationFunction.average:
return arr[1].reduce((sum: number, state) => sum + parseFloat(state), 0) / arr[1].length
default:
Expand Down
Loading