From bb56060f8a8d0f52976a25a9cdf4d06cd638e633 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Fri, 14 Aug 2026 00:00:56 +0300 Subject: [PATCH 1/4] feat(graph): describe the moving-average span in words The smoothing span is a quarter of the graph window at that window's own sampling cadence. Deriving it once here keeps the settings hint true to what the graph plots. --- src/app/core/utils/graph-window.util.spec.ts | 21 ++++++++++++++++ src/app/core/utils/graph-window.util.ts | 26 ++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/app/core/utils/graph-window.util.spec.ts b/src/app/core/utils/graph-window.util.spec.ts index f2311fe3..499ff057 100644 --- a/src/app/core/utils/graph-window.util.spec.ts +++ b/src/app/core/utils/graph-window.util.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { resolveWindowMs, deriveDataSourceInfo, + describeSmoothingWindow, TARGET_POINTS_PER_WINDOW, MIN_SAMPLE_TIME_MS, SMOOTHING_PERIOD_FACTOR @@ -71,4 +72,24 @@ describe('graph-window.util', () => { expect(info.smoothingPeriod).toBeGreaterThanOrEqual(1); }); }); + + describe('describeSmoothingWindow', () => { + it('reports a quarter of the display window in the unit that reads best', () => { + expect(describeSmoothingWindow('Last Minute', 0)).toBe('15 s'); + expect(describeSmoothingWindow('minute', 10)).toBe('2.5 min'); + expect(describeSmoothingWindow('hour', 2)).toBe('30 min'); + expect(describeSmoothingWindow('hour', 12)).toBe('3 h'); + expect(describeSmoothingWindow('day', 4)).toBe('1 day'); + expect(describeSmoothingWindow('day', 10)).toBe('2.5 days'); + }); + + it('stays truthful for a window so short the average spans milliseconds', () => { + // 1 s of data at the 100 ms sample floor leaves a 2-point average. + expect(describeSmoothingWindow('second', 1)).toBe('200 ms'); + }); + + it('describes nothing when the window is empty', () => { + expect(describeSmoothingWindow('minute', 0)).toBe(''); + }); + }); }); diff --git a/src/app/core/utils/graph-window.util.ts b/src/app/core/utils/graph-window.util.ts index 5f7c779f..1d94c618 100644 --- a/src/app/core/utils/graph-window.util.ts +++ b/src/app/core/utils/graph-window.util.ts @@ -47,6 +47,32 @@ export function resolveWindowMs(timeScaleFormat: TimeScaleFormat, period: number * window with a floor sampling interval for very small windows. The point count tracks the target * (never far above it), so no separate buffer cap is needed. */ +/** Largest unit first, so the first unit the span reaches is the one it is reported in. */ +const SMOOTHING_WINDOW_UNITS: readonly { ms: number; one: string; many: string }[] = [ + { ms: 24 * 60 * 60_000, one: 'day', many: 'days' }, + { ms: 60 * 60_000, one: 'h', many: 'h' }, + { ms: 60_000, one: 'min', many: 'min' }, + { ms: 1_000, one: 's', many: 's' } +]; + +/** + * How much time the moving average spans, phrased for a settings hint ('2.5 min'). The span is the + * smoothing period at the window's own sampling cadence, so it stays true to what the graph plots + * rather than restating the 25 % factor. Empty for a window with no length. + */ +export function describeSmoothingWindow(timeScaleFormat: TimeScaleFormat, period: number): string { + const windowMs = resolveWindowMs(timeScaleFormat, period); + if (windowMs <= 0) return ''; + const info = deriveDataSourceInfo(windowMs); + const spanMs = info.smoothingPeriod * info.sampleTime; + for (const unit of SMOOTHING_WINDOW_UNITS) { + if (spanMs < unit.ms) continue; + const value = Math.round((spanMs / unit.ms) * 10) / 10; + return `${value} ${value === 1 ? unit.one : unit.many}`; + } + return `${Math.round(spanMs)} ms`; +} + export function deriveDataSourceInfo(windowMs: number): IGraphDataSourceInfo { const sampleTime = windowMs > 0 ? Math.max(MIN_SAMPLE_TIME_MS, Math.round(windowMs / TARGET_POINTS_PER_WINDOW)) From 07e217e05f9f314eee5027bf4a4190b751f29291 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Fri, 14 Aug 2026 00:00:56 +0300 Subject: [PATCH 2/4] feat(graph): name the moving-average settings for what they do "Display Moving Average" never said how far back it averaged, and "Track Against Moving Average" named neither of the two things it does: it swaps the bold line with the shaded band, and it switches the widget reading from the live value to the average. Show Smoothed Trend now carries a hint with the real span, and the second control becomes a Main Series choice between Live Value and Smoothed Trend. The persisted trackAgainstAverage boolean is unchanged, so stored dashboards load as they did. The two Series cards merge into one, and Show Average Line becomes Show Window Average Line to separate it from the rolling average. Closes halos-org/skip#598 --- .../graph-display-options.component.html | 56 ++++++++++--------- .../graph-display-options.component.scss | 10 ++++ .../graph-display-options.component.spec.ts | 25 +++++++++ .../graph-display-options.component.ts | 23 +++++++- .../root-modal-widget-config.component.html | 2 + 5 files changed, 89 insertions(+), 27 deletions(-) diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.html b/src/app/widget-config/graph-display-options/graph-display-options.component.html index ee826a1e..32430e91 100644 --- a/src/app/widget-config/graph-display-options/graph-display-options.component.html +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.html @@ -39,18 +39,42 @@ name="showAverageData" [formControl]="showAverageData()" (change)="enableTrackAgainstMovingAverage($event)"> - Display Moving Average - - - Track Against Moving Average + Show Smoothed Trend + @if (smoothingWindow()) { + Moving average over the last {{ smoothingWindow() }}, a quarter of the graph window. + } +
+

Main Series

+ + Live Value + Smoothed Trend + + The reading and the bold line follow the main series. The other one becomes the shaded band. +
Vertical Data Graph +

Reference Lines

+ + Show Maximum Line + + + Show Window Average Line + + + Show Minimum Line +
@@ -130,26 +154,6 @@
-
-
-

Series

- - Show Maximum Line - - - Show Average Line - - - Show Minimum Line - -
-
diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.scss b/src/app/widget-config/graph-display-options/graph-display-options.component.scss index f0a91703..e2f7a755 100644 --- a/src/app/widget-config/graph-display-options/graph-display-options.component.scss +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.scss @@ -16,6 +16,16 @@ margin: 0px; } +.graph-option-caption { + display: block; + margin: 2px 0px 6px 0px; + color: var(--mat-sys-outline); +} + +.graph-option-subgroup { + margin-left: 25px; +} + .graph-option-radio-group { display: flex; flex-direction: column; diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts index 081411f5..c92dd743 100644 --- a/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts @@ -31,6 +31,8 @@ describe('GraphDisplayOptionsComponent', () => { yScaleMax: new UntypedFormControl({ value: 120, disabled: true }), numDecimal: new UntypedFormControl(2), color: new UntypedFormControl('contrast'), + timeScale: new UntypedFormControl('minute'), + period: new UntypedFormControl(10), ...overrides, }; @@ -88,6 +90,29 @@ describe('GraphDisplayOptionsComponent', () => { expect(yScaleSuggestedMax.disabled).toBe(false); }); + it('states the smoothing span the graph actually averages over (#598)', () => { + const text = () => (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text()).toContain('2.5 min'); + + component.period().setValue(20); + fixture.detectChanges(); + expect(text()).toContain('5 min'); + }); + + it('keeps the series toggles and their reference lines in one card (#598)', () => { + const cards = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('.flex-item-rounded-card')); + const seriesCards = cards.filter(card => (card.textContent ?? '').includes('Show Maximum Line')); + expect(seriesCards).toHaveLength(1); + expect(seriesCards[0].textContent).toContain('Display Data Points'); + }); + + it('offers the main series as a choice between the live value and the smoothed trend (#598)', () => { + const labels = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('mat-radio-button')) + .map(button => (button.textContent ?? '').trim()); + expect(labels).toContain('Live Value'); + expect(labels).toContain('Smoothed Trend'); + }); + it('should enable and disable trackAgainstAverage from checkbox events', () => { const trackAgainstAverage = component.trackAgainstAverage(); diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.ts index 67129425..a6b748f4 100644 --- a/src/app/widget-config/graph-display-options/graph-display-options.component.ts +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.ts @@ -1,5 +1,9 @@ -import { Component, OnInit, input, inject } from '@angular/core'; +import { Component, DestroyRef, OnInit, input, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { merge } from 'rxjs'; import { AppService } from '../../core/services/app-service'; +import { describeSmoothingWindow } from '../../core/utils/graph-window.util'; +import type { TimeScaleFormat } from '../../core/interfaces/graph-data.interfaces'; import { MatCardModule } from '@angular/material/card'; import { MatOptionModule } from '@angular/material/core'; import { MatSelectModule } from '@angular/material/select'; @@ -18,6 +22,7 @@ import { MatRadioChange, MatRadioModule } from '@angular/material/radio'; }) export class GraphDisplayOptionsComponent implements OnInit { private app = inject(AppService); + private readonly _destroyRef = inject(DestroyRef); readonly datasetAverageArray = input.required>(); readonly showAverageData = input.required>(); @@ -42,7 +47,12 @@ export class GraphDisplayOptionsComponent implements OnInit { readonly numDecimal = input.required>(); readonly color = input.required>(); + /** The graph window the smoothing span is derived from; owned by the Data tab. */ + readonly timeScale = input.required>(); + readonly period = input.required>(); protected colors: { label: string; value: string }[] = []; + /** How far back the moving average reaches, phrased for the hint ('2.5 min'). */ + protected smoothingWindow = signal(''); ngOnInit(): void { this.colors = this.app.configurableThemeColors; @@ -50,11 +60,22 @@ export class GraphDisplayOptionsComponent implements OnInit { this.trackAgainstAverage().disable(); } + this.refreshSmoothingWindow(); + // The span is a fraction of the graph window, so it follows edits made on the Data tab while + // this dialog stays open. + merge(this.timeScale().valueChanges, this.period().valueChanges) + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe(() => this.refreshSmoothingWindow()); + if (this.enableMinMaxScaleLimit()) { this.setValueScaleOptionsControls(this.enableMinMaxScaleLimit().value); } } + private refreshSmoothingWindow(): void { + this.smoothingWindow.set(describeSmoothingWindow(this.timeScale().value as TimeScaleFormat, this.period().value)); + } + private setValueScaleOptionsControls(enableMinMaxScaleLimit: boolean) { if (enableMinMaxScaleLimit) { this.yScaleMin()?.enable(); diff --git a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html index df0270ca..3a22206c 100644 --- a/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html +++ b/src/app/widget-config/root-modal-widget-config/root-modal-widget-config.component.html @@ -71,6 +71,8 @@
{{ titleDialog }}
[yScaleMin]="yScaleMinToControl" [yScaleMax]="yScaleMaxToControl" [numDecimal]="numDecimalToControl" + [timeScale]="timeScaleControl" + [period]="periodControl" [verticalChart]="verticalChartToControl" [inverseYAxis]="inverseYAxisToControl" [color]="colorToControl" /> From 0d0ca590fc9e242e816430ba33f9a9a204a88b8a Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Fri, 14 Aug 2026 00:00:59 +0300 Subject: [PATCH 3/4] =?UTF-8?q?Bump=20version:=201.4.1=20=E2=86=92=201.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- VERSION | 2 +- package.json | 2 +- src/assets/skip-dashboard-schema.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1945177f..c99e170e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.4.1 +current_version = 1.5.0 commit = True tag = False diff --git a/VERSION b/VERSION index 347f5833..bc80560f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.1 +1.5.0 diff --git a/package.json b/package.json index da861122..93d55f58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@halos-org/skip", - "version": "1.4.1", + "version": "1.5.0", "publishConfig": { "access": "public" }, diff --git a/src/assets/skip-dashboard-schema.json b/src/assets/skip-dashboard-schema.json index 6d941b6a..d8ffa9f1 100644 --- a/src/assets/skip-dashboard-schema.json +++ b/src/assets/skip-dashboard-schema.json @@ -581,7 +581,7 @@ "configFileVersion": 11, "configVersion": 19, "schemaVersion": 1, - "skipVersion": "1.4.1" + "skipVersion": "1.5.0" }, "widgets": [ { From 7e36c03618fdf8967b2b5aff3908ca5bbc27e8c0 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Fri, 14 Aug 2026 22:54:37 +0300 Subject: [PATCH 4/4] fix(graph): drop a main-series choice its smoothing toggle contradicts A stored trackAgainstAverage without showAverageData was disabled but kept, so switching smoothing back on moved the widget reading off the live value with no selection made. The caption also claimed an exact quarter, which the sample floor and the integer smoothing period undercut on short windows. --- .../graph-display-options.component.html | 2 +- .../graph-display-options.component.spec.ts | 14 ++++++++++++++ .../graph-display-options.component.ts | 3 +++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.html b/src/app/widget-config/graph-display-options/graph-display-options.component.html index 32430e91..444a6398 100644 --- a/src/app/widget-config/graph-display-options/graph-display-options.component.html +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.html @@ -42,7 +42,7 @@ Show Smoothed Trend @if (smoothingWindow()) { - Moving average over the last {{ smoothingWindow() }}, a quarter of the graph window. + Moving average over the last {{ smoothingWindow() }}, about a quarter of the graph window. }

Main Series

diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts index c92dd743..364ff54a 100644 --- a/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.spec.ts @@ -71,6 +71,20 @@ describe('GraphDisplayOptionsComponent', () => { expect(controls.trackAgainstAverage.disabled).toBe(true); }); + it('drops a stored main-series choice that its own smoothing toggle contradicts (#600)', () => { + // Otherwise the disabled group reads Smoothed Trend, and switching smoothing back on moves + // the widget reading off the live value without the user choosing it. + const localFixture = TestBed.createComponent(GraphDisplayOptionsComponent); + const controls = applyRequiredInputs(localFixture, { + showAverageData: new UntypedFormControl(false), + trackAgainstAverage: new UntypedFormControl({ value: true, disabled: false }) + }); + + localFixture.detectChanges(); + + expect(controls.trackAgainstAverage.value).toBe(false); + }); + it('should enable and disable fixed scale controls based on radio selection', () => { const yScaleMin = component.yScaleMin(); const yScaleMax = component.yScaleMax(); diff --git a/src/app/widget-config/graph-display-options/graph-display-options.component.ts b/src/app/widget-config/graph-display-options/graph-display-options.component.ts index a6b748f4..57abc52d 100644 --- a/src/app/widget-config/graph-display-options/graph-display-options.component.ts +++ b/src/app/widget-config/graph-display-options/graph-display-options.component.ts @@ -57,6 +57,9 @@ export class GraphDisplayOptionsComponent implements OnInit { ngOnInit(): void { this.colors = this.app.configurableThemeColors; if (this.showAverageData() && !this.showAverageData()?.value) { + // Reset as well as disable: a stored choice of the smoothed trend, held while nothing smooths, + // would otherwise take effect the moment smoothing is switched back on. + this.trackAgainstAverage().setValue(false); this.trackAgainstAverage().disable(); }